Skip to content

Python: [Bug]: A local tool's approval response is serialized to the Responses API as an MCP approval response, with no matching request → 400 #7452

Description

@antsok

Description

1. Symptom

Approving a gated local tool call ends the run with a hard error from the Responses API:

Error code: 400 - {'error': {'message': "The following MCP approval requests have approval
responses but weren't passed as input: call_OWTUay5B7jQPNHM8IGvIFaC7", 'type':
'invalid_request_error', 'param': None, 'code': None}}

Every answered approval card on the Responses path fails this way, so a gated local tool can never complete its turn.

2. Root cause

Two independent defects, and either fix alone prevents the 400.

2a. PR #7407 made the response arm unconditional

PR #7407 "Preserve approval decisions under OpenAI continuation" (95ec5b7d) split one match arm in two and dropped the storage guard from the response half:

-                case "function_approval_response" | "function_approval_request":
+                case "function_approval_request":
                     if request_uses_service_side_storage:
                         continue
                     ...
+                case "function_approval_response":
+                    prepared = self._prepare_content_for_openai(...)   # no guard

Under request_uses_service_side_storage=True the request is now omitted while the response is always emitted. Before #7407 the shared guard kept the pair consistent — both omitted under storage, both sent without it — so this asymmetry could not arise; the PR even renamed the test that had asserted it (test_prepare_messages_strips_approval_items_under_storage). The failing payload carries an mcp_approval_response and no mcp_approval_request, which is that asymmetry exactly.

The PR's reasoning is sound for a hosted/MCP approval — the service issued and stored the request, so replaying it would duplicate. It assumes every function_approval_response has a server-stored request to pair with, which is false for a local FunctionTool.

The serializer it reaches, _prepare_content_for_openai (agent_framework_openai/_chat_client.py:1965), has no hosted-tool check at all:

case "function_approval_response":
    return {
        "type": "mcp_approval_response",
        "approval_request_id": content.id,
        "approve": content.approved,
    }

agent_framework._tools._is_hosted_tool_approval exists for exactly this distinction (function_call.additional_properties["server_label"]) and is used elsewhere in core, but not here. So a local FunctionTool's approval response — which has no server_label and must never leave the process — is emitted as an mcp_approval_response whose approval_request_id is a local call_id. No mcp_approval_request with that id can exist in the input, so the request is rejected.

The function_approval_request arm immediately above does read server_label, emitting None for a local tool — inconsistent with this arm, and arguably wrong in its own right.

2b. _approval_controls_to_keep only clears a response whose result comes later

PR #7345 "Improve function approval resume and replay" (5987a679) added _approval_controls_to_keep in agent_framework/_sessions.py, called from the concrete HistoryProvider.before_run via _filter_approval_control_messages.

It walks messages in order, registers a local function_approval_response into local_response_ids_by_call_id, and only pops it when it subsequently sees a terminal function_result for that call_id. When the terminal result is seated before the response — which is the layout the approval-resume path produces — the pop happens while the map is still empty, the response is then registered and never cleared, and it is preserved into model input as "unresolved".

This contradicts the stated contract. core/AGENTS.md says: "The base HistoryProvider.before_run filters resolved wrappers from later model replay, but preserves unresolved requests/responses until a terminal result or follow-up request closes them." Measured against the shipped function with one local approval response and that call's terminal result:

Layout Response replayed?
response, then result — same message no ✅
response, then result — later message no ✅
result, then response — same message (the live layout) yes ❌
result, then response — earlier message yes ❌

The approval is equally resolved in all four. Order alone decides. Rejected decisions and repeated call_ids leak by the same mechanism.

3. Evidence

The outgoing payload, from the OpenAI client's own debug logging (wrapped here for readability):

'json_data': {'include': ['reasoning.encrypted_content'], 'input': [
  {'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': '…'}]},
  {'call_id': 'call_OWTUay5B7jQPNHM8IGvIFaC7', 'type': 'function_call_output', 'output': "File 'notes.md' not found."},
  {'type': 'mcp_approval_response', 'approval_request_id': 'call_OWTUay5B7jQPNHM8IGvIFaC7', 'approve': True},
  {'call_id': 'call_zGSd8XqalyjdoHvYs14lDQCZ', 'type': 'function_call_output', 'output': "File 'notes.md' written."},
  …

call_OWTUay… is file_access_read, a local harness tool. Both defects are visible in three lines: its terminal function_call_output precedes the response (2b), and the response is serialized as an mcp_approval_response (2a). Item-type census over one failing run:

10 function_approval_request
 5 function_approval_response
 1 mcp_approval_response      <-- the leak

A probe on the message list as the chat client receives it, immediately before upstream's serialization, shows the leftover is already present and already local:

  [0/user]      text
  [1/tool]      function_result             call_id=call_WMZ0CiDqutoPaeKXq93hjkxs
  [1/tool]      function_approval_response  id=call_WMZ0Ci…  server_label=None  HOSTED=False
  [1/tool]      function_result             call_id=call_clAPm9keQggSCUnru1CI1QcU
  [2/user]      text
  [3/assistant] function_call               call_id=call_clAPm9keQggSCUnru1CI1QcU
  [3/assistant] function_call               call_id=call_WMZ0CiDqutoPaeKXq93hjkxs
  [4/system]    text

Two things this settles. HOSTED=False — no server_label, so it is unambiguously a local tool's approval, and §2a's missing guard is what mis-serializes it. And the response sits in the same role="tool" message as its own terminal function_result, immediately after it — so §2b's order-dependence is triggered within a single message and needs no message-level reordering to bite. _replace_approval_contents_with_results was expected to replace that response with the result; it left both.

4. Reproduction

  1. Register a local FunctionTool with approval_mode="always_require". A harness-contributed tool such as file_access_read works, but nothing about the reproduction depends on the harness — any gated local tool reaches the same path.
  2. Run against the Responses API with service-side storage in effect.
  3. Drive a turn that calls the gated tool, and approve the resulting card.
  4. The next request carries an mcp_approval_response with no matching mcp_approval_request, and the API returns the 400 in §1.

Reproduced consistently on azure_foundry/gpt-5.4-mini over the Responses API. Any application that gates a local tool on this path reaches the same failure.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: 1.12.1, agent-framework-openai: 1.11.0

Python Version

No response

Additional Context

5. Proposed fix

Primary — 2a. Gate the MCP projection on the approval actually being hosted, mirroring what core already does elsewhere:

case "function_approval_response":
    if not _is_hosted_tool_approval(content):
        return {}          # local approvals are resolved in-process; never send them
    return {"type": "mcp_approval_response", "approval_request_id": content.id, "approve": content.approved}

…and the same guard on the function_approval_request arm, instead of emitting server_label: None.

Nothing is lost by dropping a local approval response. It is resolved in-process by ToolApprovalMiddleware, and its outcome already travels as the ordinary function_call_output for the same call_id — which is why the shared if request_uses_service_side_storage: continue guard could drop it for as long as it did without anyone noticing.

Secondary — 2b. Make _approval_controls_to_keep order-independent: collect terminal results per call_id in a first pass, then decide which responses are unresolved, rather than relying on the result appearing after the response.

6. The same leftover breaks two other connectors

§2b's leftover is not only a Responses-path problem. The same resolved-but-replayed local approval response reaches every provider, and two others cannot represent it either.

Ollama — an outright request failure, and independent of §2a. The AG-UI approval-resume path synthesizes a role="user" message whose only contents are approval controls (_canonical_approval_resume_messages). agent_framework_ollama's _format_user_message rejects any user message with no text or data:

ChatClientInvalidRequestException: Ollama connector currently only supports user messages
with TextContent or DataContent.

This is an inconsistency between two upstream components rather than a consequence of #7407, and it does not need the Responses path at all — it was observed on the previous pin, before #7407 and #7345 merged. It is arguably worth its own issue; it is recorded here because §2b's leftover is what puts those contents in that message.

Gemini — a latent, ordering-dependent unsigning. Since PR #7095 the Gemini 3 thought_signature travels as base64 protected_data on a text_reasoning content and is re-attached by adjacency to the call that immediately follows. _convert_message_contents implements that with a pending_signature local and clears it on every non-reasoning content before the match, so a leaked approval control between carrier and call takes the signature with it and then discards itself via case _. Measured on the pinned connector:

contents signature on the functionCall part
carrier, call present
carrier, approval_response, call None400 "Function call is missing a thought_signature in functionCall parts."
carrier, approval_request, call None → same 400
carrier, call, approval_request present

Whether it fires is ordering luck — a control after the call is harmless. Note that a separately observed Gemini 400 with the same message has a different cause (the approval replay carries no carrier at all, so there is no signature to eat); that is out of scope for this report, and this section describes only the hazard the leftover creates when a carrier is present.

Chat-completions providers are structurally immune. Mistral, OpenRouter and Hugging Face go through OpenAIChatCompletionClient, whose converter drops approval contents outright — verified offline against the real client, which yields [('tool', result)], [('assistant', tool_calls)], and [] for an approvals-only message. They never reach _prepare_content_for_openai's Responses arm.

Metadata

Metadata

Assignees

No one assigned

    Labels

    likely-fixedpythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions