tool_search aborts with 'unsupported payload' when using non-native API endpoints (Ollama/Bifrost)

Open 💬 4 comments Opened May 1, 2026 by rnett

What issue are you seeing?

When using Codex with a large number of MCP tools (exceeding DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD: usize = 100), Codex correctly defers the tools and exposes the tool_search handler and the MCP namespace stubs (e.g. mcp__tilth__). However, when using a non-native compatibility layer like Ollama or LiteLLM to serve models, calling the tool_search tool always results in an immediate turn abort.

The specific error seen in codex-tui.log is:
Fatal error: tool_search handler received unsupported payload

Looking at codex-rs/core/src/tools/router.rs, when a tool call comes in via a generic OpenAI-compatible FunctionCall (which is what Ollama/LiteLLM output, rather than the internal ToolSearchCall type), the router wraps the arguments in a ToolPayload::Function { arguments }.
However, the tool_search handler (core/src/tools/handlers/tool_search.rs) explicitly expects ToolPayload::ToolSearch { arguments } and throws a fatal error if it receives ToolPayload::Function, completely breaking the deferred tools workflow for local/compatibility-layer models.

What steps can reproduce the bug?

  1. Configure an OpenAI-compatible proxy (like Ollama, Bifrost, or LiteLLM) in config.toml as the model provider.
  2. Load enough MCP servers/tools so the total exceeds 100, triggering the deferred tool behavior.
  3. Prompt the model to search for a deferred tool (e.g., "Use tool_search to find tilth tools").
  4. The model generates a standard JSON-RPC tool call for tool_search with valid arguments like {"query": "tilth"}.
  5. The turn immediately aborts with the "unsupported payload" fatal error in the logs.

What is the expected behavior?

The tool_search handler should gracefully parse standard ToolPayload::Function payloads if the arguments map correctly to SearchToolCallParams, rather than strictly requiring ToolPayload::ToolSearch.
Alternatively, router.rs should check if the incoming FunctionCall has the name tool_search and wrap it in a ToolPayload::ToolSearch before dispatching.

Additional information

This makes using extensive MCP toolsets with local or non-GPT models essentially impossible, as they cannot pull deferred tools into context. A simple fallback in the match payload block of ToolSearchHandler::handle to deserialize from the string arguments of ToolPayload::Function would instantly solve this interoperability bug.

View original on GitHub ↗

4 Comments

rnett · 3 months ago

Additional Context on the 100-Tool Threshold

As a follow-up, it's worth noting how easily a standard user hits the DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD limit in a normal environment.

When running /mcp, the built-in codex_apps server alone provides almost 90 distinct tools (primarily the GitHub connector suite: github_add_comment_to_issue, github_create_pull_request, github_fetch_blob, etc.).

Because this built-in connector consumes ~90 slots immediately, adding even a single moderately sized MCP server (like Playwright, which has 20+ tools) pushes the session over the 100-tool threshold. This means the deferred tool_search behavior is essentially the default state for anyone using connectors alongside external MCP servers, making this payload mismatch bug a hard blocker rather than an edge case for users on local/compatibility endpoints.

truffle-dev · 3 months ago

Verified the diagnosis against current HEAD.

codex-rs/core/src/tools/router.rs:188-201 routes ResponseItem::FunctionCall (the only call shape Ollama/LiteLLM emit) to ToolPayload::Function { arguments } whenever session.resolve_mcp_tool_info(&tool_name).await returns None, which is the case for tool_search since it isn't an MCP tool.

codex-rs/core/src/tools/handlers/tool_search.rs:60-66 then matches only ToolPayload::ToolSearch { arguments } and falls through to FunctionCallError::Fatal for any other variant.

SearchToolCallParams already has Deserialize (the native path at router.rs:213-217 parses serde_json::from_value(arguments) for the same struct), so option (a) is a clean fallthrough:

let args = match payload {
    ToolPayload::ToolSearch { arguments } => arguments,
    ToolPayload::Function { arguments } => {
        serde_json::from_str(&arguments).map_err(|err| {
            FunctionCallError::RespondToModel(format!(
                "failed to parse tool_search arguments: {err}"
            ))
        })?
    }
    _ => {
        return Err(FunctionCallError::Fatal(format!(
            "{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload"
        )));
    }
};

RespondToModel rather than Fatal on parse failure keeps a malformed tool_search call from aborting the turn and matches the empty-query / zero-limit behavior already in the handler at :67-83.

This also likely explains the regression at #19871: if DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD = 100 was added or lowered between v0.116 and v0.117, configurations with ~100+ MCP tools that previously got direct exposure now route through tool_search and immediately abort on Ollama/LiteLLM endpoints.

bharat2808 · 1 month ago

I ran into this same class of problem while using Codex with Ollama and a custom Responses API provider.

The proxy runs locally between Codex and the configured Responses API upstream. It currently handles the relevant Codex-specific tool shapes by:

  • flattening type: "namespace" tools into standard function tools;
  • mapping returned flat function names back to Codex’s { namespace, name } format;
  • lifting newer additional_tools input items into the top-level tool list;
  • translating custom tools such as apply_patch;
  • translating ordinary tool_search function calls back into native tool_search_call items.

This allowed me to keep Codex app plugins and tools available while using Ollama in my setup.

GitHub: https://github.com/bharat2808/codex-ollama-proxy
npm: https://www.npmjs.com/package/codex-ollama-proxy

XAEAZii · 17 days ago

I’m hitting the same underlying issue with Codex CLI + a custom Responses API provider backed by vLLM, using nvidia/Qwen3.6-27B-NVFP4.

My relevant vLLM configuration is:

vLLM 0.23.0
--reasoning-parser qwen3
--enable-auto-tool-choice
--tool-call-parser qwen3_coder

The main practical problem in my setup is MCP tool discovery / context size.

When starting a Codex session with several MCP servers configured, Codex falls back to exposing the MCP tools directly to the model. In my current setup this consumes roughly 30k tokens of context just for tool definitions at session startup.

Ideally, MCP tools should remain deferred behind tool_search, even for non-native/custom OpenAI-compatible providers.

For providers such as vLLM, I think a useful compatibility path would be to expose tool_search as an ordinary function tool:

tool_search(query: ...)

and then have Codex translate the returned ordinary function_call internally into its native ToolSearch payload / execution path.

In other words, something along the lines of:

Custom provider:

function_call("tool_search", args)
↓
Codex compatibility layer
↓
native ToolSearch handler
↓
deferred MCP tools are discovered

This would allow custom providers to benefit from Codex’s existing local MCP tool discovery/indexing without requiring every backend (vLLM, Ollama, LM Studio, etc.) to implement Codex/OpenAI-specific tool_search_call wire semantics.

The proxy workaround mentioned above demonstrates that this translation is feasible, but I think having the compatibility layer directly in Codex CLI would be much better than requiring an additional proxy.

For my use case the difference is significant: instead of sending ~30k tokens of MCP schemas up front, the model could initially see only tool_search and receive the relevant tool definitions on demand.

IMO this is becoming increasingly important as Codex supports custom Responses API providers: without deferred tool discovery, larger MCP setups have a very substantial context cost.