Incorrect behavior of Conversation API with MCP Approval output item

When calling the Responses API and passing an MCP server in the tools list with require_approval: 'always', there is incorrect behavior in the Conversation API:

const conversation = await openAIClient.conversations.create()

let response = await openAIClient.responses
  .stream({
    input: "Call tool from mcp server_1",
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

let input = []
for (const item of response.output) {
  if (item.type === "mcp_approval_request") {
    // We have to provide original mcp_approval_request to next input, otherwise we will get error:
    // 400 The following MCP approval requests have approval responses but weren't passed as input
    input.push(item, {
      type: "mcp_approval_response",
      approve: true,
      approval_request_id: item.id
    })
  }
}

// Send approval back to api
response = await openAIClient.responses
  .stream({
    input,
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

// Then user send another message to chat
response = await openAIClient.responses
  .stream({
    input: "Call tool from mcp server_2",
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

input = []
for (const item of response.output) {
  if (item.type === "mcp_approval_request") {
    // Now we don't have to provide original mcp_approval_request to the API input otherwise we will get error:
    // 400 Duplicate item found with id mcpr_...
    // but why we should do that on the first API call?
    input.push({
      type: "mcp_approval_response",
      approve: true,
      approval_request_id: item.id
    })
  }
}

// Send approval back to api
response = await openAIClient.responses
  .stream({
    input,
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

If, in the very first response from the Responses API, we receive an output item with the type mcp_approval_request, the API expects us to send that same mcp_approval_request and mcp_approval_response as input in response to it:

However, if we then receive a second request for a different server within the same conversation, sending another mcp_approval_request results in an error, stating that we already have this element in the current history and that we sent a duplicate:

Essentially, the API behaves inconsistently under the same conditions.

To be honest, the Conversation API currently feels like a black box, the documentation doesn’t explain edge cases, such as tool invocations or interactions with MCP servers when using the conversation ID in the API. The docs only have a short paragraph saying that instead of passing prev_response_id, you can simply pass conversation_id, which in my opinion doesn’t clarify anything about what should actually be sent in the input afterward. From my understanding, we shouldn’t be sending any previous elements like mcp_approval_request or function_call when returning responses to them, but as we can see from the issue above, that’s not how it actually works.

1 Like

Conversations should work identically to “previous_response_id”. Anything emitted by the AI for your action, such as a function call (with ID pairing) or an approval (also requiring paring of a response ID that matches the API output) should be contained in the conversation state. It should not be echoed back to the server, as it is already there and must be there.

If the API complains that there was no approval request, that is the fault of the API, whether using previous response ID or conversations.

The only case where you would include all previous “output” back as “input” is when completely self-managing the conversation history (and then you have the choice to expire old message turns also).

I would not adapt the input to sending back and echoing the approval request simply because it seemed necessary from faults such as “approval but no approval request was made” (or whatever the API outputs). If you inferred you need to do that, the conversations API is at fault (along with its many other faults). Try the previous response ID mechanism and see if instead that works as expected as a drop-in. Report the MCP issue (and you can also join other topics here with conversations+MCP issues). These helpers and applications written for you are all subject to the short attention span of OpenAI to keep them updated with new methods of the API - or to even deliver them in working condition.

Setting up personal MCP (as a replacement for developer functions anyway), and then using conversations (which I would not use because of similar failings and failure to provide a chat budget), not in my wheelhouse of replicating other’s issues. So, here is annotating your code by AI after explaining and furnishing documentation. It notes the current state of you sending back the output.

/* -- commentary ---------------------------------------------------------------
STEP 0 — Create a fresh server-side conversation thread.

This establishes a persisted conversation state. Because `store: true` is used
on subsequent calls, the server *will* remember prior assistant outputs
(including MCP approval requests) inside this conversation.
----------------------------------------------------------------------------- */
const conversation = await openAIClient.conversations.create()

/* -- commentary ---------------------------------------------------------------
STEP 1 — Send a user message that can trigger MCP tool calls.

Because `require_approval: "always"` is set for both servers, the model will
emit one or more `mcp_approval_request` items in `response.output`. Each request
has a *unique* `id` (prefixed `mcpr_...`) that must be paired exactly once with
an `mcp_approval_response` carrying `approval_request_id: <that id>`.
----------------------------------------------------------------------------- */
let response = await openAIClient.responses
  .stream({
    input: "Call tool from mcp server_1",
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,                 // Persist this turn's outputs into the conversation
    parallel_tool_calls: true
  })
  .finalResponse()

/* -- commentary ---------------------------------------------------------------
STEP 2 — Build inputs to send approvals.

⚠ ISSUE A (root cause of later “400 Duplicate item found with id mcpr_...”)  
Below you push the *original* `mcp_approval_request` object (`item`) back into
`input`. That object (and its `id`) is **already** persisted in the conversation
because `store: true` was used. Sending it again re-injects a message with the
same ID into the same conversation state, which eventually leads to the 400
duplicate-id error when the server detects the repeated `mcpr_*` item.

✓ Correct mechanism with conversation-based state:  
Only send an `mcp_approval_response` that references the `approval_request_id`.
Do **not** send the `mcp_approval_request` object itself. The request is already
in the conversation; you only need to send the acceptance that pairs to its ID.

✎ Correction (illustrative; do NOT edit the live code here):  
// input.push({
//   type: "mcp_approval_response",
//   approve: true,
//   approval_request_id: item.id
// })

Additional note on mutability assumptions:  
`item` is a reference to the server-produced message. Pushing that object back
into `input` does not “update” or “replace” the stored request; it *duplicates* it
client-side and asks the server to store it again. That is the duplication.
----------------------------------------------------------------------------- */
let input = []
for (const item of response.output) {
  if (item.type === "mcp_approval_request") {
    // We have to provide original mcp_approval_request to next input, otherwise we will get error:
    // 400 The following MCP approval requests have approval responses but weren't passed as input

    /* -- commentary -----------------------------------------------------------
    This comment reflects a workaround that is only applicable if you *do not*
    link turns via `previous_response_id`. Because you *are* using `conversation`
    with `store: true`, the approval request is already present in the thread.
    Re-sending it is a protocol misuse with conversation-scoped state.

    If you want to avoid sending the request object at all times, the two correct
    options are:
      1) Keep using `conversation: conversation.id` and send *only*
         `mcp_approval_response` (preferred with conversation state), or
      2) Use `previous_response_id` to chain to the turn that emitted the request.

    Where to detect a server-side fault instead of a client issue:
      Log each `item.id` (the `mcpr_*`) as received. If the server emits the same
      `mcpr_*` for two distinct approval moments, that would indicate a server bug.
    --------------------------------------------------------------------------- */

    input.push(
      item,  // ❌ DO NOT echo the original `mcp_approval_request` when using conversations.
             //    This is the duplication source: you’re re-sending a persisted message.
      {
        type: "mcp_approval_response",
        approve: true,
        approval_request_id: item.id  // ✅ Only this response is needed to pair with the stored request.
      }
    )
  }
}

/* -- commentary ---------------------------------------------------------------
STEP 3 — Send the first batch of approvals.

This call appends your inputs to the conversation. Because the code above
also pushed the request object(s), the conversation now contains an extra copy
of each `mcp_approval_request` message (same `mcpr_*` IDs) in addition to the
responses. That sets you up for the “duplicate id” failure on a later turn.
----------------------------------------------------------------------------- */
response = await openAIClient.responses
  .stream({
    input,
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

/* -- commentary ---------------------------------------------------------------
STEP 4 — Send another user message that may trigger a *new* approval request.

If the model calls a tool again, the server should issue a *new* approval
request with a *new* `mcpr_*` ID. The client must read that *new* ID and pair it
with a new `mcp_approval_response`.
----------------------------------------------------------------------------- */
response = await openAIClient.responses
  .stream({
    input: "Call tool from mcp server_2",
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

/* -- commentary ---------------------------------------------------------------
STEP 5 — Build the second approval batch.

👍 Here you *do not* push the original `mcp_approval_request`, which is correct
for conversations. You only send its acceptance referencing the *new* ID.

⚠ ISSUE B (how the wrong/old ID gets re-used by mistake)
Although the loop uses `response.output` (good), the earlier duplication from
STEP 2 means the conversation may already contain duplicated `mcpr_*` items.
If you were to accidentally reuse a cached `item` or an old `approval_request_id`
(from an earlier turn) rather than reading the *latest* ID from *this* new
`response.output`, you would send the previous ID again — which is exactly the
“duplicate id found with 'mcpr_...'” failure.

✎ Correction (conceptual; do NOT edit live code here):
  • Always read `item.id` fresh from the *most recent* `response.output`.  
  • Never carry forward or reuse IDs across turns.  
  • Never include the `mcp_approval_request` object itself in `input` for conversations.

Optional guard (conceptual): keep a Set of seen approval IDs and assert the new
`item.id` hasn’t been seen before. If the server *does* emit a repeated ID here,
that points to an API/server bug, not client code.
----------------------------------------------------------------------------- */
input = []
for (const item of response.output) {
  if (item.type === "mcp_approval_request") {
    // Now we don't have to provide original mcp_approval_request to the API input otherwise we will get error:
    // 400 Duplicate item found with id mcpr_...
    // but why we should do that on the first API call?

    /* -- commentary -----------------------------------------------------------
    You should not provide the original request in *either* call when using
    `conversation`. The first call included it erroneously, causing duplication.
    The correct consistent behavior across all turns is:
      input.push({ type: "mcp_approval_response", approve: true, approval_request_id: item.id })
    and nothing else.
    --------------------------------------------------------------------------- */

    input.push({
      type: "mcp_approval_response",
      approve: true,
      approval_request_id: item.id  // ✅ Must be the *new* ID from this turn’s `response.output`.
    })
  }
}

/* -- commentary ---------------------------------------------------------------
STEP 6 — Send the second batch of approvals.

If you only send `mcp_approval_response` items (no echoed requests) and each
`approval_request_id` is the *new* one you just received, the server will pair
them 1:1 without duplicates.

Alternative chaining model (comment-only, not applied here):
  Instead of relying solely on `conversation`, you can set
  `previous_response_id: <the response.id that emitted the approval request>`.
  In that mode, you *still* only send the `mcp_approval_response` items; never
  the original request objects.
----------------------------------------------------------------------------- */
response = await openAIClient.responses
  .stream({
    input,
    model: "gpt-4.1",
    conversation: conversation.id,
    tools: [
      {
        type: "mcp",
        server_label: "server_1",
        require_approval: "always"
      },
      {
        type: "mcp",
        server_label: "server_2",
        require_approval: "always"
      }
    ],
    store: true,
    parallel_tool_calls: true
  })
  .finalResponse()

/* -- commentary ---------------------------------------------------------------
DIAGNOSTIC CHECKPOINTS (comment-only):

1) To confirm client-side duplication (most likely here):
   • Log each received `mcpr_*` once: `console.debug("mcpr received:", item.id)`
   • Ensure you never push the corresponding `mcp_approval_request` object into
     `input`. Only push `mcp_approval_response`.

2) To identify a server/API fault:
   • If two *different* approvals in separate turns arrive with the *same*
     `mcpr_*` ID, that’s an API bug. The server must mint a fresh `mcpr_*` for
     each approval request.

Summary of the fault in this script:
   The line `input.push(item, ...)` in STEP 2 re-sends a persisted approval
   request object into the conversation, duplicating an item with a fixed ID.
   Later turns then encounter “400 Duplicate item found with id mcpr_...” when
   that ID appears again. The correct mechanism for conversations is to send
   only `mcp_approval_response { approval_request_id: <new id> }` and never the
   original `mcp_approval_request` item itself.
----------------------------------------------------------------------------- */

Then: OpenAI has even had faults with tools reusing the same ID later in the same conversation. The generating mechanism obviously was poor. This is not outside of the realm of possibilities with the MCP implementation also. You could collect an array of seen mcpr_ approval IDs, and raise an error yourself if they are reused in later chat session calls with more user input.