From 255adc8f0b3c420b6ecfd694376d4504b5343b5c Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Thu, 6 Aug 2026 14:17:33 +0900 Subject: [PATCH] Drop local approval controls from the Responses API input A local tool's approval is resolved in-process, so the provider never issued a matching MCP approval item. Serializing one produced an orphaned mcp_approval_request (server_label: null) or an mcp_approval_response referencing an id the API never saw. The function-calling loop already strips these before the provider sees them, so the hole was the raw OpenAIChatClient path (and FoundryChatClient, which wraps it). Both cases in toResponsesInput now gate on isHostedApproval, the same server_label test the loop layer uses. Mirrors the upstream Python fix (microsoft/agent-framework#7462). Co-Authored-By: Claude Fable 5 --- packages/openai/src/hosted-tools.test.ts | 10 +++- packages/openai/src/to-openai.ts | 11 ++++ .../src/to-openai.wire-fallbacks.test.ts | 58 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/openai/src/hosted-tools.test.ts b/packages/openai/src/hosted-tools.test.ts index f9124e3..0b61688 100644 --- a/packages/openai/src/hosted-tools.test.ts +++ b/packages/openai/src/hosted-tools.test.ts @@ -281,7 +281,15 @@ describe('hosted tool input mapping', () => { type: 'function_approval_response', id: 'mcpr_1', approved: true, - functionCall: { type: 'function_call', callId: 'mcpr_1', name: 'delete', arguments: '{}' }, + // `approvalResponse()` copies the request's call verbatim, so a hosted decision + // carries the `server_label` that marks it as the provider's to settle. + functionCall: { + type: 'function_call', + callId: 'mcpr_1', + name: 'delete', + arguments: '{}', + additionalProperties: { server_label: 'docs' }, + }, }, ], }, diff --git a/packages/openai/src/to-openai.ts b/packages/openai/src/to-openai.ts index 1287973..98e9566 100644 --- a/packages/openai/src/to-openai.ts +++ b/packages/openai/src/to-openai.ts @@ -12,6 +12,7 @@ import type { import { ChatClientError, isFunctionTool, + isHostedApproval, MESSAGE_SOURCE_KEY, resolveResponseFormat, topLevelMediaType, @@ -566,6 +567,11 @@ export function toResponsesInput( break; } case 'function_approval_request': { + // A local tool's approval is resolved in-process: the provider never issued a matching + // approval item, so serializing one would orphan it on the wire. + if (!isHostedApproval(content)) { + break; + } // The framework models a hosted MCP approval as an approval request; the Responses API // models it as an output item that has to be replayed to keep the pairing. items.push({ @@ -578,6 +584,11 @@ export function toResponsesInput( break; } case 'function_approval_response': { + // Only a hosted decision has a matching approval request on the provider; a local + // decision replayed as `mcp_approval_response` would reference an id the API never saw. + if (!isHostedApproval(content)) { + break; + } items.push({ type: 'mcp_approval_response', approval_request_id: content.id, diff --git a/packages/openai/src/to-openai.wire-fallbacks.test.ts b/packages/openai/src/to-openai.wire-fallbacks.test.ts index 26637c2..1fc4784 100644 --- a/packages/openai/src/to-openai.wire-fallbacks.test.ts +++ b/packages/openai/src/to-openai.wire-fallbacks.test.ts @@ -282,6 +282,64 @@ describe('stateless replay validation', () => { }); }); +describe('approval serialization boundaries', () => { + const localCall = { + type: 'function_call', + callId: 'local_1', + name: 'ask_user', + arguments: '{}', + } as const; + const localApprovalMessages: Message[] = [ + { + role: 'assistant', + contents: [{ type: 'function_approval_request', id: 'local_approval_1', functionCall: localCall }], + }, + { + role: 'user', + contents: [ + { + type: 'function_approval_response', + id: 'local_approval_1', + approved: true, + functionCall: localCall, + }, + ], + }, + ]; + + it.each([ + ['stateless', false], + ['storage', true], + ])('drops local approval controls from the input (%s)', (_mode, serviceStorage) => { + // A local tool's approval is resolved in-process; the provider never issued a matching + // MCP approval request, so serializing either half would create an orphaned item. + expect(toResponsesInput(localApprovalMessages, { serviceStorage })).toEqual([]); + }); + + it('keeps a hosted approval pair on the wire', () => { + const hostedCall = { + type: 'function_call', + callId: 'mcpr_1', + name: 'sensitive_action', + arguments: '{}', + additionalProperties: { server_label: 'hosted_server' }, + } as const; + const items = toResponsesInput([ + { + role: 'assistant', + contents: [{ type: 'function_approval_request', id: 'mcpr_1', functionCall: hostedCall }], + }, + { + role: 'user', + contents: [ + { type: 'function_approval_response', id: 'mcpr_1', approved: true, functionCall: hostedCall }, + ], + }, + ]); + expect(items.map((item) => item.type)).toEqual(['mcp_approval_request', 'mcp_approval_response']); + }); +}); + describe('tool declarations and choices', () => { it('passes a non-function tool without a spec through as itself', () => { const opaque = { type: 'web_search' } as unknown as Tool;