Skip to content

fix(mcp): preserve explicit tool_choice for the initial responses request - #1963

Merged
slin1237 merged 1 commit into
mainfrom
fix/mcp-responses-tool-choice
Jul 24, 2026
Merged

slin1237 merged 1 commit into
mainfrom
fix/mcp-responses-tool-choice

Conversation

@slin1237

@slin1237 slin1237 commented Jul 23, 2026 •

Copy link
Copy Markdown
Member

Description

Problem

The OpenAI router's MCP tool loop rewrote every tool_choice to "auto" in prepare_mcp_tools_as_functions before the initial model request, silently dropping explicit "required", "none", and specific-function choices (#1945). A model that only calls tools under "required" could skip the MCP call entirely.

Solution

Exactly the semantics the reporter proposed, which the gRPC responses loops already implement per-iteration:

  • Initial request: an explicit string tool_choice ("required"/"none"/"auto") is preserved. Object choices are remapped through the MCP/hosted→function rewrite: they resolve by name against the rewritten function tools (a hosted type like {"type": "image_generation"} matches the function of the same name and becomes {"type": "function", "name": "image_generation"}); anything unmappable falls back to "auto", since the referenced tool no longer exists in the rewritten array. Naive verbatim preservation would 400 upstream — the openai-responses e2e caught exactly that (Tool choice 'image_generation' not found in 'tools') on the first revision of this PR.
  • Resume payloads (build_resume_payload): tool_choice is forced to "auto" — the tool call already happened, and a cloned "required" would otherwise loop until the tool-call limit.
  • Requests that sent "auto" (or nothing) behave exactly as before.

The two other tool_choice writes in the OpenAI router are response-side echo normalization and are untouched — with this fix a preserved "required" now also echoes back faithfully. The gRPC responses loops (regular + Harmony) already had iteration-0-preserves/then-auto logic and need no change.

Changes

  • routers/openai/mcp/tool_loop.rs — prepare_mcp_tools_as_functions inserts "auto" only when tool_choice is absent/null; build_resume_payload sets "auto" alongside the resumed tools.
  • Unit tests: string choice preserved through tool rewriting, hosted-typed choice remapped to its function form, unmappable object choice downgraded to "auto", missing choice defaults to "auto", resume forces "auto" over a preserved "required".

Test Plan

  • cargo test -p smg: lib 1258 passed (19 tool-loop tests incl. the 5 new); all integration binaries green (api 106, routing 94, spec 96, security 50, …; 0 failures).
  • cargo clippy --all-targets -- -D warnings, cargo +nightly fmt --check.
  • Repro from the issue: tool_choice: "required" with a deepwiki MCP tool now reaches the upstream model as "required" on the first request and "auto" on post-tool iterations.
Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes
  • (Optional) Documentation updated
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

Summary by CodeRabbit

  • Bug Fixes
    • Preserved explicit tool selection preferences when translating MCP tools into OpenAI function tools, including remapping supported tool choices to their rewritten counterparts.
    • When a provided tool choice can’t be mapped, it now safely falls back to automatic selection.
    • Ensured resumed requests continue tool execution reliably by forcing automatic tool choice when resumed with a non-empty tools set.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added model-gateway Model gateway crate changes openai OpenAI router changes labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

MCP tool-loop payload construction now preserves and remaps explicit initial tool_choice values, defaults missing or unmappable choices to "auto", and forces "auto" for resumed calls with tools. Crate-local access to function-tool naming is enabled, with tests covering these behaviors.

Changes

Tool choice semantics

Layer / File(s) Summary
Payload remapping and validation
model_gateway/src/routers/openai/mcp/tool_loop.rs, model_gateway/src/routers/openai/responses/mod.rs, model_gateway/src/routers/openai/responses/utils.rs
Initial MCP payloads preserve or remap tool choices against rewritten function tools, unmappable choices default to "auto", resumed payloads with tools force "auto", and tests cover the updated behavior. The function-tool naming helper is crate-visible for this remapping.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • lightseekorg/smg#1945 — Directly covers preserving explicit initial tool choices and forcing "auto" for resumed MCP tool-loop payloads.

Possibly related PRs

  • lightseekorg/smg#368 — Refactors MCP session and tool-loop configuration used by the updated tool-choice handling.
  • lightseekorg/smg#730 — Extracts the MCP tool-loop module that this behavioral change modifies.

Suggested labels: mcp, tests

Suggested reviewers: key4ng, claude

Poem

I’m a bunny with tools in my nest,
“Required” stays put when it’s best.
On resume, “auto” takes the lead,
Tests hop along to guard the speed.
MCP loops now curl up and rest.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preserving explicit tool_choice values for the initial MCP responses request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mcp-responses-tool-choice

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

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@model_gateway/src/routers/openai/mcp/tool_loop.rs`:
- Around line 1944-1994: Add tests alongside
prepare_preserves_explicit_tool_choice and
prepare_defaults_missing_tool_choice_to_auto for tool_choice set to null and for
a structured specific-function choice. Verify prepare_mcp_tools_as_functions
changes null to "auto" while preserving the structured function selection
unchanged.
🪄 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: 12da5b5b-c4db-448f-8ac5-e3da8a71236d

📥 Commits

Reviewing files that changed from the base of the PR and between fec23aa and 5ce36c4.

📒 Files selected for processing (1)
  • model_gateway/src/routers/openai/mcp/tool_loop.rs

Comment thread model_gateway/src/routers/openai/mcp/tool_loop.rs

@claude claude 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.

Clean, well-scoped fix with good test coverage. The initial-request-preserves / resume-forces-auto split is correct and matches the existing gRPC behavior. No issues found.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ce36c43b8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +354 to +355
if obj.get("tool_choice").is_none_or(Value::is_null) {
obj.insert("tool_choice".to_string(), Value::String("auto".to_string()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Convert non-function choices before forwarding

When a Responses request uses MCP routing with a hosted/MCP-specific tool_choice object (for example {"type":"web_search_preview"} or {"type":"mcp","server_label":"wiki","name":"ask_wiki"}), this guard preserves that original object even though the preceding rewrite has replaced the tools array with synthesized function tools. The initial upstream /v1/responses request now carries a tool_choice whose type is no longer present in tools, so the model worker can reject it or fail to force the intended MCP/builtin call before the gateway gets a chance to dispatch it. Please normalize these non-function choices to the corresponding synthesized function choice (or fall back to auto) instead of preserving every non-null value.

Useful? React with 👍 / 👎.

…uest

The OpenAI-router MCP tool loop rewrote every tool_choice to "auto"
before the first model request, so "required"/"none"/specific choices
were silently dropped. The initial request now keeps the client's
explicit choice; resume payloads still force "auto" so a preserved
"required" cannot loop until the tool-call limit. Matches the gRPC
responses loop's existing per-iteration semantics.

Closes #1945

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
@slin1237
slin1237 force-pushed the fix/mcp-responses-tool-choice branch from 5ce36c4 to e7de196 Compare July 23, 2026 23:46

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7de196386

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Some(name)
if tools
.iter()
.any(|tool| utils::function_tool_name(tool) == Some(name)) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid forcing client functions into MCP execution

When a request mixes a caller-owned function tool with any MCP-routed tool and sets tool_choice to that function, this check now preserves the forced function choice because the retained user function is present in tools_json. The non-streaming tool loop later extracts every function_call and executes it via session.execute_tool_result, which only contains MCP-exposed tools, so a forced user function like get_weather is converted into an MCP execution error instead of being returned to the client. Either pass user-function calls through or avoid preserving forced choices for functions that are not MCP-exposed.

Useful? React with 👍 / 👎.

Some(name)
if tools
.iter()
.any(|tool| utils::function_tool_name(tool) == Some(name)) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Map hosted choices through configured MCP tool names

Fresh evidence after the earlier comment is that the new remapper only compares the hosted tool_choice.type string to the rewritten function names. In normal builtin routing the exposed function name comes from builtin_tool_name (for example builtin_type: image_generation with builtin_tool_name: generate_image), so {"type":"image_generation"} misses here and is downgraded to auto; the initial request no longer forces the hosted tool call and models that rely on a forced choice can skip it. The hosted type needs to be resolved through the builtin routing/session mapping before falling back.

Useful? React with 👍 / 👎.

@slin1237
slin1237 merged commit 4cd2647 into main Jul 24, 2026
89 of 91 checks passed
@slin1237
slin1237 deleted the fix/mcp-responses-tool-choice branch July 24, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model-gateway Model gateway crate changes openai OpenAI router changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant