
impure0xntk/litellm-guardrail-codex-mcp
Developer toolsA LiteLLM that lets OpenAI Codex CLI work with model providers that do not understand Codex's MCP tool format.
Overview
A LiteLLM that lets OpenAI Codex CLI work with model providers that do not understand Codex's MCP tool format. Codex CLI sends its MCP (Model Context Protocol) servers to the model using a non-standard type: "namespace" tool object. OpenAI's /v1/responses endpoint understands it. Providers such as OpenRouter do not. The tools are silently dropped and never reach the model, so the assistant behaves as though it has no tools at all. This guardrail flattens each namespace into ordinary type: "function" tools named mcp__ before the request leaves the proxy: The model sees a standard tool it can call, and its tool call comes back under that same flattened name. Because a LiteLLM custom guardrail can rewrite the raw /v1/responses request body from inside the proxy you already run. Mutating data in async_pre_call_hook propagates all the way to the upstream request. This was verified empirically against litellm 1.105.
README
litellm-guardrail-codex-mcp
A LiteLLM Custom Guardrail that lets OpenAI Codex CLI work with model providers that do not understand Codex’s MCP tool format.
The problem
Codex CLI sends its MCP (Model Context Protocol) servers to the model using a non-standard type: "namespace" tool object. OpenAI’s /v1/responses endpoint understands it. Providers such as OpenRouter do not. The tools are silently dropped and never reach the model, so the assistant behaves as though it has no tools at all.
A Codex request looks like this:
{
"model": "claude-sonnet-4.5",
"tools": [
{
"type": "namespace",
"name": "mcp__filesystem",
"tools": [
{ "name": "read_file", "description": "Read a file", "parameters": { "...": "..." } }
]
}
]
}
The fix
This guardrail flattens each namespace into ordinary type: "function" tools named mcp____ before the request leaves the proxy:
{
"tools": [
{
"type": "function",
"name": "mcp__filesystem__read_file",
"description": "Read a file",
"parameters": { "...": "..." }
}
]
}
The model sees a standard tool it can call, and its tool call comes back under that same flattened name.
Why a guardrail and not a second proxy? Because a LiteLLM custom guardrail can rewrite the raw
/v1/responsesrequest body from inside the proxy you already run. Mutatingdatainasync_pre_call_hookpropagates all the way to the upstream request. This was verified empirically against litellm 1.105.1 for both streaming and non-streaming requests: the mock upstream receivedmcp__filesystem__read_fileas atype: "function"tool. No extra hop, no extra process, and/chat/completionstraffic stays untouched.
Installation
uv pip install litellm-guardrail-codex-mcp
The guardrail module path is litellm_guardrail_codex_mcp.CodexMCPNamespaceGuardrail.
Configuration
Merge this into your LiteLLM proxy config (see examples/config.example.yaml):
guardrails:
- guardrail_name: codex-mcp-namespace-compat
litellm_params:
guardrail: litellm_guardrail_codex_mcp.CodexMCPNamespaceGuardrail
mode: [pre_call, post_call]
default_on: true
Two settings here are mandatory:
| Setting | Why |
|---|---|
default_on: true |
Without it LiteLLM’s should_run_guardrail() returns False and the hook never runs. The guardrail silently does nothing. |
mode includes post_call |
The streaming iterator hook only fires when post_call is present. With mode: pre_call alone, has_iterator_override stays false and SSE responses are never observed. |
Optional settings
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Set false to install without rewriting anything. |
target_models |
null |
Restrict to matching models, e.g. ["openrouter/*"]. Null applies to every attached route. |
strict_validation |
true |
Raise HTTP 400 on an unconvertible namespace instead of forwarding it to a provider that will silently drop it. |
streaming |
true |
Process Responses API SSE events. |
logging |
false |
Emit one concise INFO line per conversion. |
restore_namespace_names |
false |
See below. |
Booleans are read from YAML, so unquoted tokens such as no, off, yes and on arrive as strings. Every boolean field is normalized: "false", "no", "off" and "0" all mean false, and anything unrecognized falls back to the documented default. Without this, restore_namespace_names: "false" is Python-truthy and would silently enable restoration.
Why restore_namespace_names is off by default
The model returns tool calls named mcp__filesystem__read_file. It is tempting to rewrite that back to the bare MCP name read_file, but you should not: current Codex resolves the flattened mcp____ name against its own tool registry. Rewriting it to read_file breaks tool execution, because Codex can no longer tell which MCP server to dispatch to (see openai/codex#26234).
So by default the guardrail leaves the flattened name alone. Set restore_namespace_names: true only if you know your client expects bare names.
Guarantees
These are enforced by tests, not just intent:
- Request-scoped mapping. Each request builds its own flat-name to
{namespace, tool_name}table, stashed per request. No global mutable state, so concurrent requests cannot see each other’s mappings. The table is keyed internally and never forwarded to the provider: upstream sees onlyinput,model,streamandtools. - Never guessed. A tool name that was not mapped during this request is left exactly as-is. The guardrail never invents a mapping.
call_idandargumentsare preserved verbatim. Only thenamefield is ever touched.- SSE is handled structurally, never as string surgery. Argument delta events are passed through by object. Deltas are never reordered, dropped, duplicated, or pattern-replaced, so
previous_response_idhistory stays intact. - No secret logging. Logs carry counts and metadata (
namespaces_flattened,tools_flattened) only. Never prompts, arguments, or API keys. - Diagnostics carry exactly the fields the spec allows: whether namespace conversion happened, tool counts before and after, conversion elapsed time (
conversion_ms), unsupported shapes, and a per-requestcorrelation_idthat ties the pre-call and post-call lines together. None of these keys reach upstream; the integration tests assert that. - Failure is loud. With
strict_validation: true, a malformed namespace yields HTTP 400 rather than a silent forward to a provider that would drop it.
Development
This project is developed with Nix.
nix develop # dev shell with python 3.13, uv, pytest, ruff
nix run .#lint # ruff check src tests
nix run .#format # ruff format src tests
Without Nix:
uv venv && uv pip install -e ".[dev]"
pytest tests/
Tests
128 tests across six concerns, all in tests/. tests/test_integration.py boots the real LiteLLM proxy ASGI app pointed at a recording mock upstream and asserts on the exact JSON the upstream receives. That is what proves the guardrail genuinely rewrites the request, rather than only proving the helper functions are correct in isolation.
Request isolation (requirement 7) is covered by test_concurrent_requests_do_not_share_mappings, which interleaves 24 requests across two namespaces while the upstream returns each request a tool call belonging to the other namespace. A correct per-request table leaves those foreign names untouched; a shared table would rewrite them.
The fixes in this repository were validated by mutation testing, not by green checkmarks: each fix is reverted in a scratch harness to confirm its test actually fails. Guardrail hooks silently do nothing when misconfigured, so a test that cannot fail is worthless here.
| Module | Covers |
|---|---|
test_transforms.py |
Flattening rules for all input shapes, including malformed ones |
test_request_hooks.py |
Request rewriting, field preservation, config gating |
test_response_mapping.py |
Per-request mapping, no-guessing, isolation |
test_streaming.py |
SSE events, typed pydantic events, pass-through integrity |
test_integration.py |
Real litellm proxy + mock upstream, streaming and non-streaming |
Verified against
litellm 1.105.1, Python 3.13.
Two implementation details worth knowing if you maintain this:
- There is no
litellm.__version__attribute. Read the installed version withimportlib.metadata.version("litellm"). - Guardrail status metadata uses
add_standard_logging_guardrail_information_to_request_data(...), not theset_guardrail_statusthat some docs reference. The latter does not exist in 1.105.1.
License
Apache License 2.0. See LICENSE.
Install
This server does not publish a one-line install command.
Open the repository installation guide