Skip to content

MCP tools silently unavailable with llama-server backends — add option to flatten namespace tool specs to functions #36942

Open

Description

@zuzupebbles

What variant of Codex are you using?

CLI

What feature would you like to see?

A per-provider compatibility option to expose MCP tools as plain type: "function" entries
(with prefixed names) instead of a type: "namespace" entry, for Responses API backends that
do not implement the namespace tool type.

Why: since ~0.144, codex sends each configured MCP server's tools as ONE type: "namespace"
entry in the Responses tools array (create_tool_spec in codex-rs/core/src/tools/handlers/mcp.rs,
unconditional). Backends that implement /v1/responses by translating to chat completions — e.g.
llama.cpp's llama-server — skip every non-function tool type (tools/server/server-chat.cpp:
"unsupported Responses tool type ... skipped"). The result is fully silent: built-in tools work,
codex mcp list shows the server enabled, the server's stdio handshake and tools/list succeed,
yet the model never sees any MCP tool, in the TUI and in ACP clients alike. There is no config
escape today: ProviderCapabilities::namespace_tools is hard-true for custom TOML providers,
and no flatten path exists.

Ask (either would work):

  • honor a namespace_tools = false key on [model_providers.<id>], falling back to the
    pre-namespace behavior (flat prefixed function specs); or
  • a [features]/provider-level mcp_tool_compat = "flatten" option with the same effect.

Feasibility evidence: we run a proxy in front of llama-server that does exactly this
translation and codex works end to end with local models: expand each namespace entry to flat
functions named <namespace>__<tool> on the request, and rewrite the model's function_call
items back to the split {"namespace": ..., "name": ...} form on the response (codex's tool
router resolves calls by exact (namespace, name) lookup, so both directions are needed).
Having codex emit the flat form natively would remove the need for that middleware for every
local-model user.

Additional information

  • Reproduction: any custom model_provider with wire_api = "responses" pointed at
    llama-server (current master), one stdio MCP server under [mcp_servers.*], new session →
    ask the model to list its callable tools: built-ins only, no mcp__* tools, no error anywhere
    (llama-server logs the skip at WARN).
  • Verified against codex 0.146.0 (npm) and main d1fb77d (2026-08-04); llama.cpp master as of the
    same date still skips non-function Responses tool types, so the gap is real on both sides today.
  • Possibly the same underlying mechanism as MCP tools silently unavailable with DeepSeek official setup (custom models.json: supports_search_tool=true + tool_mode=null) #36382 ("MCP tools silently unavailable with DeepSeek
    official setup" — custom provider, wire_api = "responses", resources visible but no callable
    MCP tools): if DeepSeek's Responses implementation also drops unknown tool types, that report's
    models.json theory would be a red herring and this option would fix it too.
  • Happy to test a build; the middleware workaround above is in production for us, so this is a
    compatibility improvement for the local/OSS-backend ecosystem rather than an urgent blocker.

Activity

  1. added
    CLIIssues related to the Codex CLI
    mcpIssues related to the use of model context protocol (MCP) servers
    custom-modelIssues related to custom model providers (including local models)
    configIssues involving config.toml, config keys, config merging, or config updates
    on Aug 4, 2026
  2. github-actions commented on Aug 4, 2026

    @github-actions
    Contributor

    Potential duplicates detected. Please review them and close your issue if it is a duplicate.

    Powered by Codex Action

  3. madmax24-ubuntu commented on Aug 18, 2026

    @madmax24-ubuntu

    I built and published a compatibility bridge that implements the exact workaround described here for Codex + llama.cpp: https://github.com/madmax24-ubuntu/codex-proxy-llama.cpp

    It expands each Codex MCP namespace tool into flat functions before forwarding to llama.cpp, then reconstructs the namespace/name mapping on the response path so Codex can route the call correctly. The bridge also handles native apply_patch, Responses SSE/event normalization, replayable tool history, reasoning handling, and compaction.

    It is currently working end-to-end with local Qwen + llama.cpp, and the installer/doctor are included. Sharing it here both as a workaround for users who need MCP now and as a concrete reference implementation for a native Codex flatten option.

  4. madmax24-ubuntu commented on Aug 18, 2026

    @madmax24-ubuntu

    I have a working middleware implementation of the exact two-way flatten/restore path described here: https://github.com/madmax24-ubuntu/codex-proxy-llama.cpp

    It expands Codex type:"namespace" MCP specs into flat function tools for llama.cpp, then rewrites returned function calls back into the namespace/name form Codex expects. I’m using it end-to-end with local Qwen through wire_api = "responses".

    The bridge also normalizes Codex-specific Responses behavior that local backends commonly miss (apply_patch, tool-history replay, SSE framing, reasoning events, compaction), but the namespace translation itself is isolated and may be useful as a reference implementation for a native namespace_tools = false option.

    There is an installer and doctor.py in the repo if anyone wants to test the workaround without patching Codex/llama.cpp.

  5. chrisbrittan commented on Aug 23, 2026

    @chrisbrittan

    Confirming this against a second backend — Ollama (the report cites llama.cpp/llama-server), and adding the failure mode, which I think matters for prioritisation.

    Setup: codex-cli 0.149.0, Ollama 0.32.15, macOS 27, qwen3-coder:30b (Q4, ~3B active MoE), via --oss --local-provider ollama (wire_api = "responses").

    Captured request (proxy between Codex and Ollama, 100,655 bytes):

    function   x11   ← all Codex built-ins (exec_command, update_plan, apply_patch …)
    namespace  x9    ← every configured MCP server
    custom x1, web_search x1
    

    So none of the MCP tools reach the model as callable functions — matching this report exactly.

    How it fails is the problem. Ollama doesn't reject the namespace entry; it surfaces it to the model flattened into prompt text, so the model sees mcp__atlassian.getVisibleJiraProjects and tries to call it with that dotted name — which Codex won't accept. In its own words mid-session: "the previous attempt failed". After that it degrades into emitting raw Qwen XML as assistant text:

    <function=mcp__jira_mcp.search_issues>
    <parameter=jql>status = 'Open' ORDER BY created DESC</parameter>
    </function>
    

    Note mcp__jira_mcp — an invented server name; the real one is atlassian. Asked to check Jira, it then explored the repo with shell tools and produced a confident, wrong diagnosis (that a credential was missing, with remediation steps naming a real script). Verified outside the sandbox, that credential was fine. So the user-visible symptom isn't "tool unavailable" — it's a plausible answer to a problem that doesn't exist.

    Positive control — this is not model capability. Same model, same endpoint, tools sent as flat type:"function":

    tools offered result
    1 correct structured tool_calls, 0.4s
    30 (with realistic decoys) correct, 1.3s
    aggregate operation-selector shape correct — mcp__atlassian({"operation":"getVisibleJiraProjects"})

    And end-to-end through a harness that sends flat function tools, the same model chained atlassianUserInfo → getAccessibleAtlassianResources → getVisibleJiraProjects against live Jira and returned the right projects. The encoding is the only difference.

    One related note: supports_search_tool = true in a custom model catalog makes this worse, not better. Ollama silently drops {"type":"tool_search"} too, so the deferral hides every MCP tool behind a tool the model can never see. Confirmed with a control — offered a tool_search entry alongside a normal function named marker_tool_alpha, the model listed the function and never mentioned tool_search.

    +1 for the per-provider flatten option proposed here. Thanks @madmax24-ubuntu for the bridge — useful to know there's a working path in the meantime.

  6. oelmekki commented on Sep 2, 2026

    @oelmekki

    Hello, fellow humans!

  7. oelmekki commented on Sep 2, 2026

    @oelmekki

    For the record, an other way to fix it: I used the patch here: https://github.com/xiaomangold/llama.cpp/tree/namespace-tool-fix

    I merged the branch with latest llama.cpp, still applies without issue. I've been using llama.cpp/Qwen-3.8 with Codex since this week-end, having it using Firefox MCP and other namespaced tools. This is obviously a hack, but if anyone want a llama.cpp workaround before it's fixed upstream, that's one possibility.

    An other thing I had to change specifically for Qwen is their Jinja template. Codex is sending system/developer messages in the middle of the messages list, while Qwen only accept them at the top of the list. The template I used for Qwen (3.8, 27b, UDQ4 from Unsloth):

    {%- set image_count = namespace(value=0) %}
    {%- set video_count = namespace(value=0) %}
    {%- macro render_content(content, do_vision_count, is_system_content=false) %}
        {%- if content is string %}
            {{- content }}
        {%- elif content is iterable and content is not mapping %}
            {%- for item in content %}
                {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
                    {%- if is_system_content %}
                        {{- raise_exception('System message cannot contain images.') }}
                    {%- endif %}
                    {%- if do_vision_count %}
                        {%- set image_count.value = image_count.value + 1 %}
                    {%- endif %}
                    {%- if add_vision_id %}
                        {{- 'Picture ' ~ image_count.value ~ ': ' }}
                    {%- endif %}
                    {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
                {%- elif 'video' in item or item.type == 'video' %}
                    {%- if is_system_content %}
                        {{- raise_exception('System message cannot contain videos.') }}
                    {%- endif %}
                    {%- if do_vision_count %}
                        {%- set video_count.value = video_count.value + 1 %}
                    {%- endif %}
                    {%- if add_vision_id %}
                        {{- 'Video ' ~ video_count.value ~ ': ' }}
                    {%- endif %}
                    {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
                {%- elif 'text' in item %}
                    {{- item.text }}
                {%- else %}
                    {{- raise_exception('Unexpected item type in content.') }}
                {%- endif %}
            {%- endfor %}
        {%- elif content is none or content is undefined %}
            {{- '' }}
        {%- else %}
            {{- raise_exception('Unexpected content type.') }}
        {%- endif %}
    {%- endmacro %}
    {%- if not messages %}
        {{- raise_exception('No messages provided.') }}
    {%- endif %}
    {%- set sysns = namespace(count=0, text='') %}
    {%- for message in messages %}
        {%- if sysns.count == loop.index0 and (message.role == 'system' or message.role == 'developer') %}
            {%- set sys_content = render_content(message.content, false, true)|trim %}
            {%- if sys_content %}
                {%- set sysns.text = sysns.text + ('\n' if sysns.text else '') + sys_content %}
            {%- endif %}
            {%- set sysns.count = sysns.count + 1 %}
        {%- endif %}
    {%- endfor %}
    {%- set num_sys = sysns.count %}
    {%- set merged_system = sysns.text %}
    {%- set reasoning_instructions = '' %}
    {%- if enable_thinking is undefined or enable_thinking is true %}
        {%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
        {%- if resolved_reasoning_effort == 'high' %}
            {%- set resolved_reasoning_effort = 'xhigh' %}
        {%- endif %}
        {%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
            {{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ '. Supported types are xhigh (default), medium, and low.') }}
        {%- endif %}
        {%- if resolved_reasoning_effort == 'xhigh' %}
            {%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
        {%- elif resolved_reasoning_effort == 'low' %}
            {%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
        {%- endif %}
    {%- endif %}
    {%- if tools and tools is iterable and tools is not mapping %}
        {{- '<|im_start|>system\n' }}
        {%- if reasoning_instructions %}
            {{- reasoning_instructions + '\n\n' }}
        {%- endif %}
        {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
        {%- for tool in tools %}
            {{- "\n" }}
            {{- tool | tojson }}
        {%- endfor %}
        {{- "\n</tools>" }}
        {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
        {%- if merged_system %}
            {{- '\n\n' + merged_system }}
        {%- endif %}
        {{- '<|im_end|>\n' }}
    {%- else %}
        {%- if merged_system %}
            {{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '')  + merged_system + '<|im_end|>\n' }}
        {%- elif reasoning_instructions %}
            {{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
        {%- endif %}
    {%- endif %}
    {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
    {%- for message in messages[::-1] %}
        {%- set index = (messages|length - 1) - loop.index0 %}
        {%- if ns.multi_step_tool and message.role == "user" %}
            {%- set content = render_content(message.content, false)|trim %}
            {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
                {%- set ns.multi_step_tool = false %}
                {%- set ns.last_query_index = index %}
            {%- endif %}
        {%- endif %}
    {%- endfor %}
    {%- for message in messages %}
        {%- if loop.index0 >= num_sys %}
        {%- set content = render_content(message.content, true)|trim %}
        {%- if message.role == "system" or message.role == "developer" %}
    		{{- '<|im_start|>user\n[This was originally a ' + message.role + ' message]\n' + content + '<|im_end|>\n' }}
        {%- elif message.role == "user" %}
            {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
        {%- elif message.role == "assistant" %}
            {%- set reasoning_content = '' %}
            {%- if message.reasoning_content is string %}
                {%- set reasoning_content = message.reasoning_content %}
            {%- endif %}
            {%- set reasoning_content = reasoning_content|trim %}
            {%- if preserve_thinking is undefined or preserve_thinking is true or loop.index0 > ns.last_query_index %}
                {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
            {%- else %}
                {{- '<|im_start|>' + message.role + '\n' + content }}
            {%- endif %}
            {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
                {%- for tool_call in message.tool_calls %}
                    {%- if tool_call.function is defined %}
                        {%- set tool_call = tool_call.function %}
                    {%- endif %}
                    {%- if tool_call.name is not defined or tool_call.name is none %}
                        {{- raise_exception('Tool call is missing a function name.') }}
                    {%- endif %}
                    {%- if loop.first %}
                        {%- if content|trim %}
                            {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
                        {%- else %}
                            {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
                        {%- endif %}
                    {%- else %}
                        {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
                    {%- endif %}
                    {%- if tool_call.arguments is mapping %}
                        {%- for args_name, args_value in tool_call.arguments|items %}
                            {{- '<parameter=' + args_name + '>\n' }}
                            {%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %}
                            {{- args_value }}
                            {{- '\n</parameter>\n' }}
                        {%- endfor %}
                    {%- elif tool_call.arguments is string %}
                        {%- if tool_call.arguments|trim %}
                            {{- raise_exception('Tool call arguments for function "' + (tool_call.name | string) + '" were passed as a JSON string. Parse them into an object before calling apply_chat_template.') }}
                        {%- endif %}
                    {%- elif tool_call.arguments is defined and tool_call.arguments is not none %}
                        {{- raise_exception('Tool call arguments for function "' + (tool_call.name | string) + '" must be an object/mapping or a JSON string.') }}
                    {%- endif %}
                    {{- '</function>\n</tool_call>' }}
                {%- endfor %}
            {%- endif %}
            {{- '<|im_end|>\n' }}
        {%- elif message.role == "tool" %}
            {%- if loop.previtem and loop.previtem.role != "tool" %}
                {{- '<|im_start|>user' }}
            {%- endif %}
            {{- '\n<tool_response>\n' }}
            {{- content }}
            {{- '\n</tool_response>' }}
            {%- if not loop.last and loop.nextitem.role != "tool" %}
                {{- '<|im_end|>\n' }}
            {%- elif loop.last %}
                {{- '<|im_end|>\n' }}
            {%- endif %}
        {%- else %}
            {{- raise_exception('Unexpected message role.') }}
        {%- endif %}
        {%- endif %}
    {%- endfor %}
    {%- if add_generation_prompt %}
        {{- '<|im_start|>assistant\n' }}
        {%- if enable_thinking is defined and enable_thinking is false %}
            {{- '<think>\n\n</think>\n\n' }}
        {%- else %}
            {{- '<think>\n' }}
        {%- endif %}
    {%- endif %}
    {#- Unsloth fixes - developer role, merged system messages, tool calling #}
    

    The one change I did in Unsloth's template is replacing the line that raise exception on system message with:

        {%- if message.role == "system" or message.role == "developer" %}
    		{{- '<|im_start|>user\n[This was originally a ' + message.role + ' message]\n' + content + '<|im_end|>\n' }}
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIconfigIssues involving config.toml, config keys, config merging, or config updatescustom-modelIssues related to custom model providers (including local models)enhancementNew feature or requestmcpIssues related to the use of model context protocol (MCP) servers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions