microsoft / microsoft/agent-framework
Python: [Bug]: A local tool's approval response is serialized to the Responses API as an MCP approval response, with no matching request → 400
@moonbox3 is already working on this.
Since Aug 3, 2026.
- Dominant language
- Python
- Stars
- 13.6k
- Forks
- 2.3k
- Avg merge
- 2d 45m
- Merged PRs (30d)
- 358
Description
### 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](https://github.com/microsoft/agent-framework/pull/7407) "Preserve approval decisions under OpenAI continuation" (`95ec5b7d`) split one `match` arm in two and dropped the storage guard from the response half:
```python
- 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:
```python
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](https://github.com/microsoft/agent-framework/pull/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_id`s leak by the same mechanism.
## 3. Evidence
The outgoing payload, from the OpenAI client's own debug logging (wrapped here for readability):
```python
'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
```markdown
```
### Error Messages / Stack Traces
```markdown
```
### 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:
```python
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` | **None** → `400 "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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.