Skip to content

fix: preserve Codex MCP namespaces through translation - #384

Open
bgrins wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
bgrins:spike/codex-mcp-namespaces
Open

fix: preserve Codex MCP namespaces through translation#384
bgrins wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
bgrins:spike/codex-mcp-namespaces

Conversation

@bgrins

@bgrins bgrins commented Aug 12, 2026

Copy link
Copy Markdown

What

This is a fix for Codex wrapping MCP tools into namespaces (openai/codex#33263) in a way that makes MCP tools unavailable to other backends.

Codex sends MCP tools inside {"type": "namespace", ...} container. Chat-only servers accept {"type": "function"} and drop the container, so the model never sees any MCP tool.

The request codex now flattens the container into its child function tools, and the response path re-attaches the namespace to each returned function_call{"type": "function_call", "name": "search", "namespace": "mcp__docs__"} — which is what Codex needs to dispatch back to the right MCP server. Covers the buffered body and Responses streaming events.

Why

Pointing Codex at any Chat backend through Switchyard silently loses all MCP tools.

How tested

Rust-only change

  • uv run ruff check . clean
  • uv run mypy switchyard clean
  • uv run pytest tests/ green — 134 passed, 2 deselected
  • cargo fmt --all --check / cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo test green for affected crates — 236 tests
  • Manual smoke: Codex 0.147.0 → switchyard-server → chat-only upstream, with a live stdio MCP server. Without the fix Codex answers NO_TOOL_AVAILABLE and the MCP log stops at tools/list; with it, tools/call fires and the tool's token reaches the answer. Same result on Ollama (gemma4) and OpenRouter (google/gemini-3.5-flash).

Checklist

  • One class per file; filename = snake_case of the primary class. — n/a, Rust only
  • New public symbols exported from switchyard/__init__.py.__all__ if intended for downstream use. — n/a, no Python API change
  • Unit tests added for new components / bug fixes.
  • README / --help updated if customer-facing surface changed. — n/a, transparent to existing configs
  • Commits signed off (Signed-off-by: Your Name <email>) per the DCO.

Notes for reviewers

I don't know where a namespace feature is heading in terms of provider support or clients outside of Codex, so I might be missing something. I don't see any prior reference to it in this project, though.

Be aware that colliding tool names will be flattened here (if two MCP servers expose the same tool name, neither call gets a namespace and one would disappear). This could be fixed by adding a prefix or similar, but that might change behavior if someone was prompting by the tool name, so I left that alone.

Summary by CodeRabbit

  • New Features

    • Added support for preserving MCP tool namespaces in OpenAI Responses requests and responses.
    • Supports namespace restoration for both buffered and streaming function-call output.
    • Added recursive handling for nested namespace tool containers.
  • Bug Fixes

    • Prevents loss of tool namespace information during request translation.
    • Safely avoids restoring namespaces when tool names are ambiguous.
  • Tests

    • Added coverage for namespace restoration, nested tools, and duplicate tool names.

@bgrins
bgrins requested a review from a team as a code owner August 12, 2026 18:24
@bgrins

bgrins commented Aug 12, 2026

Copy link
Copy Markdown
Author

Here's an e2e reproduction against local Ollama (default) or OpenRouter — both reproduce identically.

repro-codex-mcp-namespace.sh
#!/usr/bin/env bash
#
# Reproduction for openai/codex#33263.
#
# Codex sends MCP tools inside a non-standard {"type":"namespace",...} container.
# Chat-only upstreams accept only {"type":"function"} and drop it, so the model
# never sees any MCP tool.
#
# Builds a proxy (Switchyard) without and with the fix, points Codex at each
# through a chat-only upstream, and runs one prompt against a live MCP server.
#
# Expected: unfixed answers NO_TOOL_AVAILABLE and the MCP log stops at
# tools/list; fixed dispatches tools/call and returns the token A7F3-2C91.
#
# Backends: BACKEND=ollama (default, local) or BACKEND=openrouter (needs
# OPENROUTER_API_KEY). Both reproduce identically.
#
# Requires: codex on PATH, a Rust toolchain, python3, git; ollama running for
# the default backend.

set -euo pipefail

WORKDIR="${WORKDIR:-/tmp/codex-mcp-repro}"
REPO_URL="${REPO_URL:-https://github.com/bgrins/Switchyard}"
FIX_BRANCH="${FIX_BRANCH:-fix/codex-mcp-namespaces}"
BASE_BRANCH="${BASE_BRANCH:-main}"
BACKEND="${BACKEND:-ollama}"
PORT_UNFIXED="${PORT_UNFIXED:-4124}"
PORT_FIXED="${PORT_FIXED:-4125}"

SERVER_PID=""
cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true; }
trap cleanup EXIT

for tool in codex cargo python3 git curl; do
  command -v "$tool" >/dev/null || { echo "missing prerequisite: $tool" >&2; exit 1; }
done

case "$BACKEND" in
  ollama)
    MODEL="${MODEL:-gemma4}"
    command -v ollama >/dev/null || { echo "missing prerequisite: ollama" >&2; exit 1; }
    curl -sf -m 5 http://localhost:11434/api/version >/dev/null \
      || { echo "ollama is not running on :11434" >&2; exit 1; }
    installed=$(ollama list)
    grep -q "^${MODEL}" <<<"$installed" \
      || { echo "model '$MODEL' not pulled; run: ollama pull $MODEL" >&2; exit 1; }
    ;;
  openrouter)
    MODEL="${MODEL:-google/gemini-3.5-flash}"
    [ -n "${OPENROUTER_API_KEY:-}" ] \
      || { echo "BACKEND=openrouter requires OPENROUTER_API_KEY" >&2; exit 1; }
    ;;
  *)
    echo "unknown BACKEND '$BACKEND' (expected ollama or openrouter)" >&2; exit 1 ;;
esac

mkdir -p "$WORKDIR"
cd "$WORKDIR"

cat > mcp_secret_server.py <<'PYEOF'
#!/usr/bin/env python3
import json
import os
import sys

LOG = os.environ["MCP_LOG"]
SECRET = "A7F3-2C91"

TOOL = {
    "name": "get_secret_word",
    "description": "Returns the secret word. The ONLY way to learn the secret word.",
    "inputSchema": {"type": "object", "properties": {}, "required": []},
}


def log(message):
    with open(LOG, "a") as handle:
        handle.write(message + "\n")


def send(payload):
    sys.stdout.write(json.dumps(payload) + "\n")
    sys.stdout.flush()


def main():
    log("server started")
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            request = json.loads(line)
        except json.JSONDecodeError:
            continue

        method = request.get("method")
        request_id = request.get("id")
        log("-> %s" % method)

        if request_id is None:
            continue

        if method == "initialize":
            params = request.get("params") or {}
            send({"jsonrpc": "2.0", "id": request_id, "result": {
                "protocolVersion": params.get("protocolVersion", "2025-06-18"),
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "secret-server", "version": "0.1.0"},
            }})
        elif method == "tools/list":
            send({"jsonrpc": "2.0", "id": request_id, "result": {"tools": [TOOL]}})
        elif method == "tools/call":
            log("TOOL CALLED: %s" % (request.get("params") or {}).get("name"))
            send({"jsonrpc": "2.0", "id": request_id, "result": {
                "content": [{"type": "text", "text": "The secret word is %s." % SECRET}],
                "isError": False,
            }})
        else:
            send({"jsonrpc": "2.0", "id": request_id,
                  "error": {"code": -32601, "message": "method not found: %s" % method}})


if __name__ == "__main__":
    main()
PYEOF

if [ "$BACKEND" = ollama ]; then
  cat > proxy.toml <<TOMLEOF
schema_version = 1

[llm_clients.upstream]
format = "openai_chat"
base_url = "http://127.0.0.1:11434/v1"

[targets.remote]
id = "$MODEL"
llm_client = "upstream"

[routes.switchyard]
id = "switchyard"
type = "random"
targets = ["remote"]
TOMLEOF
else
  cat > proxy.toml <<TOMLEOF
schema_version = 1

[llm_clients.upstream]
format = "openai_chat"
base_url = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"

[targets.remote]
id = "$MODEL"
llm_client = "upstream"

[routes.switchyard]
id = "switchyard"
type = "random"
targets = ["remote"]
TOMLEOF
fi

[ -d switchyard ] || git clone --quiet "$REPO_URL" switchyard
pushd switchyard >/dev/null
git fetch --quiet origin "$BASE_BRANCH" "$FIX_BRANCH"

echo "building unfixed proxy ($BASE_BRANCH)..."
git checkout --quiet "origin/$BASE_BRANCH"
cargo build --quiet -p switchyard-server --bin switchyard-server
cp target/debug/switchyard-server "$WORKDIR/switchyard-server-unfixed"

echo "building fixed proxy ($FIX_BRANCH)..."
git checkout --quiet "origin/$FIX_BRANCH"
cargo build --quiet -p switchyard-server --bin switchyard-server
cp target/debug/switchyard-server "$WORKDIR/switchyard-server-fixed"
popd >/dev/null

# The early exit keeps the failing case from hunting for a tool it cannot see.
PROMPT='Do you have a tool named get_secret_word? If it is NOT in your available tools, reply exactly NO_TOOL_AVAILABLE and stop immediately - do not search the filesystem, do not run any commands. If it IS available, call it once and report the word.'

run_case() {
  local binary="$1" port="$2" label="$3"

  rm -f "$WORKDIR/mcp-calls.log"
  "$WORKDIR/$binary" --config "$WORKDIR/proxy.toml" --port "$port" \
    > "$WORKDIR/server-$label.log" 2>&1 &
  SERVER_PID=$!
  sleep 3

  echo
  echo "=================== $BACKEND / $label ==================="
  MCP_LOG="$WORKDIR/mcp-calls.log" OPENAI_API_KEY=switchyard codex exec \
    -c model_provider="switchyard" \
    -c model_providers.switchyard.name="switchyard" \
    -c model_providers.switchyard.base_url="http://127.0.0.1:$port/v1" \
    -c model_providers.switchyard.wire_api="responses" \
    -c model_providers.switchyard.env_key="OPENAI_API_KEY" \
    -c model_providers.switchyard.requires_openai_auth=false \
    -c mcp_servers.secret.command="python3" \
    -c mcp_servers.secret.args="[\"$WORKDIR/mcp_secret_server.py\"]" \
    -c mcp_servers.secret.env.MCP_LOG="$WORKDIR/mcp-calls.log" \
    --dangerously-bypass-approvals-and-sandbox \
    --skip-git-repo-check \
    -m switchyard \
    "$PROMPT" 2>&1 | tail -12

  echo "---- MCP server log ----"
  cat "$WORKDIR/mcp-calls.log"

  kill "$SERVER_PID" 2>/dev/null || true
  wait "$SERVER_PID" 2>/dev/null || true
  SERVER_PID=""
}

run_case switchyard-server-unfixed "$PORT_UNFIXED" unfixed
run_case switchyard-server-fixed   "$PORT_FIXED"   fixed

Output

BACKEND=openrouter, google/gemini-3.5-flash. Codex's per-run noise (session id, token counts, the repeated final message) trimmed:

building unfixed proxy (main)...
building fixed proxy (fix/codex-mcp-namespaces)...

=================== openrouter / unfixed ===================
user
Do you have a tool named get_secret_word? If it is NOT in your available tools, reply exactly NO_TOOL_AVAILABLE and stop immediately - do not search the filesystem, do not run any commands. If it IS available, call it once and report the word.
codex
NO_TOOL_AVAILABLE
---- MCP server log ----
server started
-> initialize
-> notifications/initialized
-> tools/list

=================== openrouter / fixed ===================
user
Do you have a tool named get_secret_word? If it is NOT in your available tools, reply exactly NO_TOOL_AVAILABLE and stop immediately - do not search the filesystem, do not run any commands. If it IS available, call it once and report the word.
codex
I have the `get_secret_word` tool available, so I will now call it to retrieve the secret word for you.
mcp: secret/get_secret_word started
mcp: secret/get_secret_word (completed)
codex
The secret word is `A7F3-2C91`.
---- MCP server log ----
server started
-> initialize
-> notifications/initialized
-> tools/list
-> tools/call
TOOL CALLED: get_secret_word

The unfixed run answers NO_TOOL_AVAILABLE and the MCP log stops at tools/list — the tool was never offered to the model. The fixed run dispatches tools/call and the token reaches the answer.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change preserves unambiguous MCP tool namespaces across Responses-to-Chat translation, raw model rewriting, and server response rendering. It supports buffered and streamed responses and adds regression tests for flattening and duplicate-name handling.

Changes

Responses MCP Namespace Preservation

Layer / File(s) Summary
Recursive request tool flattening
crates/switchyard-translation/src/codecs/responses/buffered.rs, crates/switchyard-translation/tests/request_translation.rs
Responses namespace containers are recursively flattened into Chat function definitions. The test verifies the function name and parameter schema.
Raw rewrite namespace restoration
crates/libsy-llm-client/src/client.rs
The raw rewrite path collects unambiguous namespace mappings and restores them in buffered and streamed function-call output. The integration test covers the translated request and restored response.
HTTP response namespace rendering
crates/switchyard-server/src/response.rs, crates/switchyard-server/src/lib.rs
The server extracts namespaces from request tools and passes them to response rendering. Rendering restores namespaces in aggregated and streamed Responses output and ignores ambiguous mappings. Tests cover both behaviors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit in the tool-call stream,
I hop through namespaces in a dream.
Flatten the tools, then bring them back,
Buffered or streamed on the same track.
MCP names now stay in sight—
Thump, thump, translated right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes preserving Codex MCP namespaces during translation, which is the main change.
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.

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: 3

🧹 Nitpick comments (1)
crates/switchyard-translation/tests/request_translation.rs (1)

456-495: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a nested namespace case.

The new code path is recursive, but this test covers only one namespace level. Add a container that holds another namespace container so the recursion stays covered.

♻️ Suggested extra assertion input
         "tools": [{
             "type": "namespace",
             "name": "mcp__filesystem",
             "description": "Filesystem MCP tools",
             "tools": [{
+                "type": "namespace",
+                "name": "mcp__filesystem__nested",
+                "tools": [{
+                    "type": "function",
+                    "name": "stat_file",
+                    "parameters": {"type": "object"}
+                }]
+            }, {
                 "type": "function",
                 "name": "list_files",
                 "description": "List files in a directory",
                 "parameters": {
                     "type": "object",
                     "properties": {"path": {"type": "string"}},
                     "required": ["path"]
                 }
             }]
         }]
🤖 Prompt for 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.

In `@crates/switchyard-translation/tests/request_translation.rs` around lines 456
- 495, Extend responses_request_flattens_codex_mcp_namespace_tools with a nested
namespace container inside the existing mcp__filesystem namespace, placing the
function tool within that nested namespace. Keep the assertions verifying the
flattened output and update them as needed to confirm the recursively discovered
function remains the emitted tool.
🤖 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 `@crates/libsy-llm-client/src/client.rs`:
- Around line 2004-2008: Correct the arguments fixture in the tool-call payload
around the visible "tool_calls" entry so the Rust string represents {"q":"rust"}
without literal backslashes. Update the related test assertion to verify
body["output"][0]["arguments"] contains the unescaped JSON arguments, preserving
coverage of the round-trip payload.

In `@crates/switchyard-server/src/response.rs`:
- Around line 49-115: The Responses namespace helpers are duplicated across
crates and must be centralized to prevent drift. Move responses_tool_namespaces,
collect_responses_tool_namespaces, and restore_responses_tool_namespaces into
switchyard-translation, confirm that crate boundary with maintainers, then
import and use the shared implementations from
crates/switchyard-server/src/response.rs#L49-115 and
crates/libsy-llm-client/src/client.rs#L807-899; preserve or relocate the
existing unit tests and the established name-resolution, ambiguity, and
function_call matching behavior.

In `@crates/switchyard-translation/src/codecs/responses/buffered.rs`:
- Around line 728-730: Update decode_responses_tools to bind the tool type once
and deduplicate flattened tools by name, retaining the first definition while
dropping later duplicates from namespace expansion and direct function entries
before encoding.

---

Nitpick comments:
In `@crates/switchyard-translation/tests/request_translation.rs`:
- Around line 456-495: Extend
responses_request_flattens_codex_mcp_namespace_tools with a nested namespace
container inside the existing mcp__filesystem namespace, placing the function
tool within that nested namespace. Keep the assertions verifying the flattened
output and update them as needed to confirm the recursively discovered function
remains the emitted tool.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d3eef6d6-b474-4f67-8c2a-7568f2fe273a

📥 Commits

Reviewing files that changed from the base of the PR and between 48b3b71 and e247318.

📒 Files selected for processing (5)
  • crates/libsy-llm-client/src/client.rs
  • crates/switchyard-server/src/lib.rs
  • crates/switchyard-server/src/response.rs
  • crates/switchyard-translation/src/codecs/responses/buffered.rs
  • crates/switchyard-translation/tests/request_translation.rs

Comment thread crates/libsy-llm-client/src/client.rs
Comment thread crates/switchyard-server/src/response.rs Outdated
Comment thread crates/switchyard-translation/src/codecs/responses/buffered.rs Outdated
Signed-off-by: Brian Grinstead <briangrinstead@gmail.com>
@bgrins
bgrins force-pushed the spike/codex-mcp-namespaces branch from e247318 to 2f5b0f4 Compare August 12, 2026 18:45
@bgrins

bgrins commented Aug 12, 2026

Copy link
Copy Markdown
Author

Fixed the coderabbit issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant