[Bug] --json and --output-schema are silently ignored when tools/MCP servers are active, resulting in malformed outputs

Resolved 💬 7 comments Opened Mar 22, 2026 by NEX-S Closed Apr 3, 2026
💡 Likely answer: A maintainer (etraut-openai, contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.116.0

What subscription do you have?

api

Which model were you using?

gpt-5.4-codex xhigh

What platform is your computer?

Darwin 25.2.0 arm64 arm

What terminal emulator and version are you using (if applicable)?

ghostty

What issue are you seeing?

When using the --json flag combined with --output-schema, the CLI is expected to strictly output parseable JSON that adheres to the provided schema. This works perfectly in normal prompts.

However, the moment we include MCP servers or tools in the request context (e.g., -c mcp_servers.local.command="..."), the model completely ignores the --output-schema constraint. Instead of valid JSON, the CLI frequently returns malformed outputs, such as:

YAML-like bare objects missing the outer {} braces.
Missing commas at the end of properties (e.g., key: "value"\nkey2: "value2").
Unquoted keys and occasional markdown block wrappers (```json).
This indicates that response_format: { strict: true } is either being silently dropped by the CLI or rejected by the backend API when tools are present, causing a silent fallback to raw, unconstrained text generation.

What steps can reproduce the bug?

  1. Create a strict schema.json:
{
  "type": "object",
  "properties": {
    "status": { "type": "string" },
    "score": { "type": "number" }
  },
  "required": ["status", "score"],
  "additionalProperties": false
}
  1. Run codex exec with tools enabled:
codex exec --json --output-schema schema.json -c mcp_servers.test.command="echo" -m gpt-5.4 "Return a status of UNSURE and a score of 6.5"
  1. Observe the malformed stdout. Instead of {"status": "UNSURE", "score": 6.5}, it often prints a bare object without braces or commas:
status: "UNSURE"
score: 6.5

What is the expected behavior?

The CLI should enforce the --output-schema strictly, even when tools/MCP servers are active in the trajectory.

If this is a hard imitation from the upstream API (i.e. tools and structured outputs cannot be mixed natively), the CLI should either:

  1. Automatically strip tools during the final generation turn to ensure strict JSON adherence.
  2. Fail loudly and throw a clear error informing the user that tools + strict schema cannot be used simultaneously, rather than silently degrading the output.

Additional information

This bug severely breaks automated agentic workflows and wrappers built around codex exec, as we shouldn't need to write complex Regular Expressions to manually inject {} and commas just to recover the stdout. Native Claude integrations handle this tool + structured output constraint flawlessly out of the box.

View original on GitHub ↗

7 Comments

NEX-S · 4 months ago

Fix for Codex CLI JSON Schema when tools are active

Root Cause

When --output-schema is provided and tools are active, the Codex CLI uses the internal /backend-api/responses endpoint. This internal endpoint expects a flattened metadata format, which differs from the public /v1/chat/completions API. Currently, if strict json_schema rules are passed to this endpoint while tools are simultaneously active, the backend silently drops or degrades the strict constraint, allowing the model to hallucinate or fallback to unformatted text (such as YAML) instead of valid JSON.

Proposed Solution: Synthetic Tool Interceptor

To enforce strict schema output natively without relying on soft prompt-engineering, we implemented a structural bypass.

  1. Synthetic Tool Injection (codex-rs/core/src/codex.rs):

When turn_context.final_output_json_schema exists and tools are populated, we dynamically inject a synthetic tool (__codex_submit_result__) into the pipeline. The parameters for this tool are strictly defined to match the provided --output-schema.

  1. Stream Intercept (codex-rs/core/src/stream_events_utils.rs):

In the stream event handler (handle_output_item_done), we explicitly intercept calls targeting __codex_submit_result__. Instead of dispatching this virtual tool to the ToolRouter (which would fail), we safely unpack the structurally-enforced JSON parameter payload and surface it upstream as a standard ResponseItem::Message.

This ensures rock-solid API-level enforcement of the requested JSON structured output, regardless of whether MCP servers or runtime CLI tools are present.

<details>
<summary><b>Click to expand the proposed code changes</b></summary>

diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index cbaabe6b8..55dda332f 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -6110,7 +6110,7 @@ pub(crate) fn build_prompt(
     input: Vec<ResponseItem>,
     router: &ToolRouter,
     turn_context: &TurnContext,
-    base_instructions: BaseInstructions,
+    mut base_instructions: BaseInstructions,
 ) -> Prompt {
     let deferred_dynamic_tools = turn_context
         .dynamic_tools
@@ -6118,7 +6118,7 @@ pub(crate) fn build_prompt(
         .filter(|tool| tool.defer_loading)
         .map(|tool| tool.name.as_str())
         .collect::<HashSet<_>>();
-    let tools = if deferred_dynamic_tools.is_empty() {
+    let mut tools = if deferred_dynamic_tools.is_empty() {
         router.model_visible_specs()
     } else {
         router
@@ -6128,6 +6128,29 @@ pub(crate) fn build_prompt(
             .collect()
     };
 
+    if let Some(schema) = &turn_context.final_output_json_schema
+        && !tools.is_empty() {
+            let parameters =
+                serde_json::from_value::<crate::tools::spec::JsonSchema>(schema.clone())
+                    .unwrap_or_else(|_| crate::tools::spec::JsonSchema::Object {
+                        properties: std::collections::BTreeMap::new(),
+                        required: None,
+                        additional_properties: None,
+                    });
+            let synthetic_tool = crate::client_common::tools::ToolSpec::Function(
+                crate::client_common::tools::ResponsesApiTool {
+                    name: "__codex_submit_result__".to_string(),
+                    description: "Submit the final output of the task. You MUST use this tool to end your response.".to_string(),
+                    strict: false,
+                    defer_loading: None,
+                    parameters,
+                    output_schema: None,
+                }
+            );
+            tools.push(synthetic_tool);
+            base_instructions.text.push_str("\n\nCRITICAL: You MUST use the `__codex_submit_result__` tool to submit your final strictly formatted JSON answer. Do NOT output the final answer as plain text.");
+        }
+
     Prompt {
         input,
         tools,
diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs
index cd77f1d5a..bc6ff5548 100644
--- a/codex-rs/core/src/stream_events_utils.rs
+++ b/codex-rs/core/src/stream_events_utils.rs
@@ -205,6 +205,53 @@ pub(crate) async fn handle_output_item_done(
     match ToolRouter::build_tool_call(ctx.sess.as_ref(), item.clone()).await {
         // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
         Ok(Some(call)) => {
+            if call.tool_name == "__codex_submit_result__" {
+                let json_text = match call.payload {
+                    crate::tools::context::ToolPayload::Function { arguments } => arguments,
+                    crate::tools::context::ToolPayload::ToolSearch { .. }
+                    | crate::tools::context::ToolPayload::Custom { .. }
+                    | crate::tools::context::ToolPayload::LocalShell { .. }
+                    | crate::tools::context::ToolPayload::Mcp { .. } => "{}".to_string(),
+                };
+                let new_item = ResponseItem::Message {
+                    id: Some(format!("msg_{}", call.call_id)),
+                    role: "assistant".to_string(),
+                    content: vec![codex_protocol::models::ContentItem::OutputText {
+                        text: json_text.clone(),
+                    }],
+                    end_turn: Some(true),
+                    phase: None,
+                };
+
+                if let Some(turn_item) = handle_non_tool_response_item(
+                    ctx.sess.as_ref(),
+                    ctx.turn_context.as_ref(),
+                    &new_item,
+                    plan_mode,
+                )
+                .await
+                {
+                    if previously_active_item.is_none() {
+                        let started_item = turn_item.clone();
+                        ctx.sess
+                            .emit_turn_item_started(&ctx.turn_context, &started_item)
+                            .await;
+                    }
+                    ctx.sess
+                        .emit_turn_item_completed(&ctx.turn_context, turn_item)
+                        .await;
+                }
+
+                record_completed_response_item(
+                    ctx.sess.as_ref(),
+                    ctx.turn_context.as_ref(),
+                    &new_item,
+                )
+                .await;
+                output.last_agent_message = Some(json_text);
+                return Ok(output);
+            }
+
             let payload_preview = call.payload.log_payload().into_owned();
             tracing::info!(
                 thread_id = %ctx.sess.conversation_id,

</details>

etraut-openai contributor · 4 months ago

Thanks for the bug report. Do you believe this is a regression? If so, which version is the last one where this works?

NEX-S · 4 months ago

Hi @etraut-openai, thanks for looking into this!

I don't believe this is a recent regression in the CLI codebase. Instead, it seems to be an underlying architectural limitation (or bug) in the /backend-api/responses endpoint itself.

Whenever tools (such as MCP servers or local shell handlers) are attached to the request payload, the backend API silently degrades or completely ignores the text.format.schema strict validation directive. Because of this backend behavior, the model's base directives bleed through, resulting in markdown wrappers (```json ... ```) or unquoted YAML-like outputs instead of the strictly enforced JSON Schema.

If the CLI ever seamlessly supported both --json and --mcp tools simultaneously in past versions, the backend might have handled tool context differently back then. However, currently, the only reliable way to enforce the schema while tools are active is by using a "hard" structural bypass. This is why the proposed fix (in the linked PR) dynamically injects a synthetic tool (__codex_submit_result__) to trick the model into strictly honoring the schema via function parameter coercion.

JaysonGeng · 3 months ago

Prepared a maintainer-ready branch for this issue, but I took a different path from the synthetic-tool proposal.

My read is that the CLI still sends the requested schema; the silent failure is that with tools/MCP active the final assistant message can degrade to YAML/plain text and codex exec still exits 0.

This branch fixes that at the codex exec boundary:

  • locally validate the final assistant message against the normalized --output-schema before printing final output
  • if the final message is invalid JSON / missing required fields / violates additionalProperties, exit non-zero instead of silently succeeding
  • emit an error event in --json mode
  • skip writing --output-last-message on validation failure

I intentionally avoided both synthetic tool injection and a global tools + output_schema prohibition.

Ready branch:

Validation:

  • cargo check -p codex-exec --tests
  • cargo test -p codex-exec --no-run
  • ran the generated codex-exec unit/integration test binaries directly; all passed
  • just fix -p codex-exec
  • just fmt
  • git diff --check
  • just argument-comment-lint blocked locally because Bazel / the script dependency is unavailable here

On the regression question: I have not confirmed a last-known-good version, so I would not call this a verified regression.

etraut-openai contributor · 3 months ago

I think the CLI harness is doing the right thing here. This appears to be a model behavior issue. We'll share the feedback with the team responsible for training our codex models.

@JaysonGeng, I don't think we should work around a model issue in the harness like you're proposing here. If you need to work around the problem in your use case, you can wrap the call to codex exec with schema validation logic.

WolfNinja32 · 2 months ago

This looks right.
But it breaks.

Here’s what’s happening.

The model is returning key/value lines.
Not real JSON.

Example:

status: "UNSURE"
score: 6.5

That breaks because there are no braces.
And there is no comma between properties.

Fixed version:

{
  "status": "UNSURE",
  "score": 6.5
}

That’s the whole problem.

If this is happening a lot, a JSON repair step can clean up cases like this before JSON.parse() sees them.

tommedema · 5 days ago

Might a better workaround be to expose a MCP tool and prompt the agent to invoke that with its required input schema instead?