fix: preserve Codex MCP namespaces through translation - #384
Conversation
|
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" fixedOutput
The unfixed run answers |
WalkthroughThe 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. ChangesResponses MCP Namespace Preservation
Estimated code review effort: 4 (Complex) | ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
crates/switchyard-translation/tests/request_translation.rs (1)
456-495: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a nested namespace case.
The new code path is recursive, but this test covers only one namespace level. Add a container that holds another
namespacecontainer 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
📒 Files selected for processing (5)
crates/libsy-llm-client/src/client.rscrates/switchyard-server/src/lib.rscrates/switchyard-server/src/response.rscrates/switchyard-translation/src/codecs/responses/buffered.rscrates/switchyard-translation/tests/request_translation.rs
Signed-off-by: Brian Grinstead <briangrinstead@gmail.com>
e247318 to
2f5b0f4
Compare
|
Fixed the coderabbit issues |
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 .cleanuv run mypy switchyardcleanuv run pytest tests/green — 134 passed, 2 deselectedcargo fmt --all --check/cargo clippy --workspace --all-targets -- -D warningscleancargo testgreen for affected crates — 236 testsNO_TOOL_AVAILABLEand the MCP log stops attools/list; with it,tools/callfires and the tool's token reaches the answer. Same result on Ollama (gemma4) and OpenRouter (google/gemini-3.5-flash).Checklist
snake_caseof the primary class. — n/a, Rust onlyswitchyard/__init__.py.__all__if intended for downstream use. — n/a, no Python API change--helpupdated if customer-facing surface changed. — n/a, transparent to existing configsSigned-off-by: Your Name <email>) per the DCO.Notes for reviewers
I don't know where a
namespacefeature 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
Bug Fixes
Tests