Skip to content

feat(responses): continue approved MCP calls - #1246

Closed
zhaowenzi wants to merge 1 commit into
mainfrom
feat/responses-approval-continue
Closed

zhaowenzi wants to merge 1 commit into
mainfrom
feat/responses-approval-continue

Conversation

@zhaowenzi

@zhaowenzi zhaowenzi commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • continue approved MCP calls for OpenAI /v1/responses non-streaming requests by resuming the approved tool locally and replaying only upstream-safe context
  • allow approval-only continuation requests when paired with previous_response_id, and sanitize replayed history so later previous_response_id turns do not resend local mcp_* items upstream
  • extend API/spec/e2e coverage and verify real smg behavior against OpenAI, including 6-turn previous_response_id flows for require_approval: \"always\" and \"never\"

Validation

  • pre-commit run --all-files
  • cargo test --package smg --test api_tests test_non_streaming_mcp_approval_continuation_uses_previous_response_id -- --nocapture
  • cargo test --package smg --test spec_test test_validate_input_items_structure -- --nocapture
  • real HTTP verification against OpenAI + DeepWiki through smg, including:
    • first-turn approval interruption parity
    • second-turn approval continuation parity
    • 6-turn previous_response_id follow-up flows confirming the model retains the original MCP result for both require_approval: \"always\" and \"never\"

Notes

  • scope remains intentionally narrow: non-streaming OpenAI Responses continuation only
  • streaming approval continuation is still out of scope

Summary by CodeRabbit

Release Notes

  • New Features

    • MCP tool approval continuations: users can now approve requested tools and seamlessly resume execution in subsequent requests without interrupting workflows.
    • Non-streaming conversation continuity: maintain full conversation history and context when resuming with approval responses.
  • Tests

    • Added comprehensive integration tests for MCP approval continuations and related validation scenarios.

Signed-off-by: Ziwen Zhao <me@zhaoziwen.com.cn>
@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6bcb0b9b-030f-4169-a0ff-c86193a45983

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/responses-approval-continue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added mcp MCP related changes tests Test changes protocols Protocols crate changes model-gateway Model gateway crate changes openai OpenAI router changes labels Apr 20, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a mechanism to continue tool execution after user approval without re-triggering the approval process. Key changes include adding continuation methods to the MCP orchestrator and session, updating protocol validation to allow approval-only requests, and modifying the model gateway's tool loop to handle resumed executions. Feedback was provided regarding a bug in the tool loop where tools were being incorrectly stripped from the resumed payload, which would prevent the model from making subsequent tool calls.

&base_payload,
&state.conversation_history,
&state.original_input,
&json!([]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The tools are being stripped from the resumed payload by passing an empty array to build_resume_payload. This will prevent the model from calling any further tools in subsequent turns of the tool loop if the original request included them. It should use &tools_json instead, which contains the tools from the initial payload.

Suggested change
&json!([]),
&tools_json,

Comment on lines +341 to +342
obj.remove("tools");
obj.remove("tool_choice");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: These removals affect ALL callers of build_resume_payload, not just the approval continuation path. Before this PR, tool_choice from the original request was preserved in every resume payload sent to the LLM during the normal tool loop (line 1116). Now it's unconditionally stripped.

This is likely the correct behavior (you don't want tool_choice: "required" forcing infinite tool calls in the loop), but it's a broader behavioral change than the approval continuation feature. Worth a comment or a dedicated test to document the intent.

Comment on lines +620 to +625
fn payload_input(payload: &Value) -> Option<ResponseInput> {
payload
.get("input")
.cloned()
.and_then(|input| serde_json::from_value(input).ok())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: Both .ok() calls in this function and in extract_approved_tool_continuation (line 656) silently swallow deserialization failures. If the stored payload or approval request arguments are malformed, the approved tool execution is silently skipped with no log output — the user approves a tool but nothing happens.

Consider adding warn! logging on the error paths, similar to deserialize_output_items_from_array in history.rs which logs "Failed to deserialize item". Something like:

fn payload_input(payload: &Value) -> Option<ResponseInput> {
    payload
        .get("input")
        .cloned()
        .and_then(|input| serde_json::from_value(input)
            .map_err(|e| warn!("Failed to deserialize payload input for approval continuation: {e}"))
            .ok())
}

arr.iter()
.filter(|item| match item.get("type").and_then(|v| v.as_str()) {
Some(ItemType::MCP_LIST_TOOLS) | Some(ItemType::MCP_CALL) => false,
Some("mcp_approval_request") => keep_approval_requests,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: "mcp_approval_request" is a raw string here, while MCP_LIST_TOOLS and MCP_CALL on the line above use ItemType constants. Same for "mcp_approval_response" on line 243. Consider adding ItemType::MCP_APPROVAL_REQUEST / ItemType::MCP_APPROVAL_RESPONSE constants for consistency and to avoid drift if the serde rename ever changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/mcp/src/core/orchestrator.rs`:
- Around line 1064-1070: Change the visibility of the approval-bypass API so it
is crate-private: locate the function continue_tool_resolved_execution and
replace its public visibility (pub) with pub(crate) so only internal callers
(e.g., the session wrapper) can invoke the approval-bypass primitive; ensure no
other external code relies on the public signature and run tests/compilation
after the change.

In `@crates/protocols/src/responses.rs`:
- Around line 1105-1114: The is_approval_only_continuation predicate currently
treats MCP approval responses with an empty approval_request_id as valid; update
the check inside the items.iter().all(...) (where
ResponseInputOutputItem::McpApprovalResponse { .. } is matched) to ensure the
embedded approval_request_id is present and non-empty (e.g., require Some(id) &&
!id.is_empty()), so that approval-only continuations only pass when a resolvable
approval_request_id exists; keep the other conditions (request.stream and
request.previous_response_id) unchanged.

In `@model_gateway/src/routers/openai/mcp/tool_loop.rs`:
- Around line 635-642: The code only finds MCP approval responses with approve
== true; instead, locate the matching
ResponseInputOutputItem::McpApprovalResponse regardless of its approve value
(replace the current find_map that yields approval_response_id with a find that
returns the full item or its id and approve flag), then in execute_tool_loop
branch on the approve boolean: if approve == true continue as before, but if
approve == false convert the denial locally into an upstream-safe completed
response (e.g., create a function_call_output/completed ResponseOutput that
strips any mcp_approval_response items) and return/post that instead of
forwarding the MCP state. Update references to
ResponseInputOutputItem::McpApprovalResponse, approval_response_id,
execute_tool_loop and mcp_approval_response in your changes.

In `@model_gateway/src/routers/openai/responses/history.rs`:
- Around line 233-263: deserialize_input_items_from_array currently only filters
out "mcp_approval_response" but must also exclude all local mcp_* items to
prevent replaying them upstream; update the iterator filter in
deserialize_input_items_from_array to mirror the logic used in
deserialize_output_items_from_array (exclude ItemType::MCP_LIST_TOOLS,
ItemType::MCP_CALL and any "mcp_approval_request"/"mcp_approval_response"
types), i.e., check item.get("type").and_then(|v| v.as_str()) and return false
for those local MCP types so they are not deserialized into the returned
Vec<ResponseInputOutputItem>.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b3fa1ec4-b779-41b4-aa74-6b71face47d4

📥 Commits

Reviewing files that changed from the base of the PR and between 5c9bd90 and 7e7bb34.

📒 Files selected for processing (9)
  • crates/mcp/src/core/orchestrator.rs
  • crates/mcp/src/core/session.rs
  • crates/protocols/src/responses.rs
  • e2e_test/responses/test_tools_call.py
  • model_gateway/src/routers/openai/mcp/tool_loop.rs
  • model_gateway/src/routers/openai/responses/history.rs
  • model_gateway/tests/api/responses_api_test.rs
  • model_gateway/tests/common/mock_worker.rs
  • model_gateway/tests/spec/responses.rs

Comment on lines +1064 to +1070
/// Continue a previously approved resolved tool call without re-entering approval.
pub async fn continue_tool_resolved_execution(
&self,
input: ToolExecutionInput,
server_key: &str,
server_label: &str,
) -> ToolExecutionOutput {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find direct call sites of the approval-bypassing continuation primitive.
rg -nP '\bcontinue_tool_resolved_execution\s*\(' --type=rust -C2

Repository: lightseekorg/smg

Length of output: 836


Keep the approval-bypass primitive crate-private.

This method intentionally skips approval; exposing it as pub makes the low-level bypass available to any downstream caller. Restrict visibility to pub(crate) since the only call site is from crates/mcp/src/core/session.rs (the session-level continuation API wrapper).

Proposed visibility change
-    pub async fn continue_tool_resolved_execution(
+    pub(crate) async fn continue_tool_resolved_execution(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mcp/src/core/orchestrator.rs` around lines 1064 - 1070, Change the
visibility of the approval-bypass API so it is crate-private: locate the
function continue_tool_resolved_execution and replace its public visibility
(pub) with pub(crate) so only internal callers (e.g., the session wrapper) can
invoke the approval-bypass primitive; ensure no other external code relies on
the public signature and run tests/compilation after the change.

Comment on lines +1105 to +1114
let is_approval_only_continuation = !request.stream.unwrap_or(false)
&& request
.previous_response_id
.as_ref()
.is_some_and(|id| !id.is_empty())
&& items
.iter()
.all(|item| matches!(item, ResponseInputOutputItem::McpApprovalResponse { .. }));

if !has_valid_input && !is_approval_only_continuation {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Require a resolvable approval request id for approval-only continuations.

This exemption currently treats {"type":"mcp_approval_response","approval_request_id":""} as a valid approval-only continuation when previous_response_id is present, so malformed requests can bypass the user-message guard and fail later in the resume path.

🛡️ Proposed fix
         let is_approval_only_continuation = !request.stream.unwrap_or(false)
             && request
                 .previous_response_id
                 .as_ref()
-                .is_some_and(|id| !id.is_empty())
-            && items
-                .iter()
-                .all(|item| matches!(item, ResponseInputOutputItem::McpApprovalResponse { .. }));
+                .is_some_and(|id| !id.trim().is_empty())
+            && items.iter().all(|item| {
+                matches!(
+                    item,
+                    ResponseInputOutputItem::McpApprovalResponse {
+                        approval_request_id,
+                        ..
+                    } if !approval_request_id.trim().is_empty()
+                )
+            });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/protocols/src/responses.rs` around lines 1105 - 1114, The
is_approval_only_continuation predicate currently treats MCP approval responses
with an empty approval_request_id as valid; update the check inside the
items.iter().all(...) (where ResponseInputOutputItem::McpApprovalResponse { .. }
is matched) to ensure the embedded approval_request_id is present and non-empty
(e.g., require Some(id) && !id.is_empty()), so that approval-only continuations
only pass when a resolvable approval_request_id exists; keep the other
conditions (request.stream and request.previous_response_id) unchanged.

Comment on lines +635 to +642
let approval_response_id = current_items.iter().rev().find_map(|item| match item {
ResponseInputOutputItem::McpApprovalResponse {
approval_request_id,
approve,
..
} if *approve => Some(approval_request_id.clone()),
_ => None,
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle rejected approval responses locally.

Line 640 only recognizes approve: true. For approve: false, continuation detection returns None, so execute_tool_loop falls through and posts the current payload upstream with the local mcp_approval_response still present. Denials should be converted locally into an upstream-safe function_call_output/completed response with approval items stripped, instead of being forwarded as MCP approval protocol state.

🐛 Suggested direction
-        } if *approve => Some(approval_request_id.clone()),
+        } => Some((approval_request_id.clone(), *approve)),

Then branch on approve after locating the matching approval request:

+// approve == true: execute via continue_tool_execution(...)
+// approve == false: record a synthetic function_call_output such as
+// "Tool call denied by user", build the resume payload with approval items
+// stripped, and do not call the MCP server.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/routers/openai/mcp/tool_loop.rs` around lines 635 - 642,
The code only finds MCP approval responses with approve == true; instead, locate
the matching ResponseInputOutputItem::McpApprovalResponse regardless of its
approve value (replace the current find_map that yields approval_response_id
with a find that returns the full item or its id and approve flag), then in
execute_tool_loop branch on the approve boolean: if approve == true continue as
before, but if approve == false convert the denial locally into an upstream-safe
completed response (e.g., create a function_call_output/completed ResponseOutput
that strips any mcp_approval_response items) and return/post that instead of
forwarding the MCP state. Update references to
ResponseInputOutputItem::McpApprovalResponse, approval_response_id,
execute_tool_loop and mcp_approval_response in your changes.

Comment on lines +233 to +263
fn deserialize_input_items_from_array(array: &Value) -> Vec<ResponseInputOutputItem> {
array
.as_array()
.map(|arr| {
arr.iter()
.filter(|item| {
item.get("type").and_then(|v| v.as_str()) != Some("mcp_approval_response")
})
.filter_map(|item| {
serde_json::from_value::<ResponseInputOutputItem>(item.clone())
.map_err(|e| warn!("Failed to deserialize item: {}. Item: {}", e, item))
.ok()
})
.collect()
})
.unwrap_or_default()
}

fn deserialize_output_items_from_array(
array: &Value,
keep_approval_requests: bool,
) -> Vec<ResponseInputOutputItem> {
array
.as_array()
.map(|arr| {
arr.iter()
.filter(|item| match item.get("type").and_then(|v| v.as_str()) {
Some(ItemType::MCP_LIST_TOOLS) | Some(ItemType::MCP_CALL) => false,
Some("mcp_approval_request") => keep_approval_requests,
_ => true,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filter all local MCP items from stored input replay.

Line 238 only drops mcp_approval_response; a later previous_response_id turn can still replay stored-input mcp_approval_request, mcp_call, or mcp_list_tools items. This breaks the “do not resend local mcp_* items upstream” invariant after an approval continuation.

Suggested fix
+fn is_local_mcp_history_item_type(item_type: &str) -> bool {
+    matches!(
+        item_type,
+        ItemType::MCP_LIST_TOOLS
+            | ItemType::MCP_CALL
+            | "mcp_approval_request"
+            | "mcp_approval_response"
+    )
+}
+
 fn deserialize_input_items_from_array(array: &Value) -> Vec<ResponseInputOutputItem> {
     array
         .as_array()
         .map(|arr| {
             arr.iter()
                 .filter(|item| {
-                    item.get("type").and_then(|v| v.as_str()) != Some("mcp_approval_response")
+                    !item
+                        .get("type")
+                        .and_then(|v| v.as_str())
+                        .is_some_and(is_local_mcp_history_item_type)
                 })
                 .filter_map(|item| {
                     serde_json::from_value::<ResponseInputOutputItem>(item.clone())
                         .map_err(|e| warn!("Failed to deserialize item: {}. Item: {}", e, item))
                         .ok()
@@
         .as_array()
         .map(|arr| {
             arr.iter()
                 .filter(|item| match item.get("type").and_then(|v| v.as_str()) {
-                    Some(ItemType::MCP_LIST_TOOLS) | Some(ItemType::MCP_CALL) => false,
                     Some("mcp_approval_request") => keep_approval_requests,
+                    Some(item_type) if is_local_mcp_history_item_type(item_type) => false,
                     _ => true,
                 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/routers/openai/responses/history.rs` around lines 233 -
263, deserialize_input_items_from_array currently only filters out
"mcp_approval_response" but must also exclude all local mcp_* items to prevent
replaying them upstream; update the iterator filter in
deserialize_input_items_from_array to mirror the logic used in
deserialize_output_items_from_array (exclude ItemType::MCP_LIST_TOOLS,
ItemType::MCP_CALL and any "mcp_approval_request"/"mcp_approval_response"
types), i.e., check item.get("type").and_then(|v| v.as_str()) and return false
for those local MCP types so they are not deserialized into the returned
Vec<ResponseInputOutputItem>.

@zhaowenzi zhaowenzi closed this Apr 20, 2026
@lightseek-bot
lightseek-bot deleted the feat/responses-approval-continue branch April 22, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mcp MCP related changes model-gateway Model gateway crate changes openai OpenAI router changes protocols Protocols crate changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant