MCP elicitation: deny_unknown_fields on McpElicitationSchema rejects top-level 'title', breaking FastMCP/Pydantic servers

Open 💬 5 comments Opened Jul 5, 2026 by fugu-ai

What happened

Codex rejects every MCP elicitation/create request from a FastMCP (Python) server with:

unknown field 'title', expected one of '$schema', 'type', 'properties', 'required'

The elicitation never reaches the user; the tool call fails. Observed on codex-cli 0.142.4 (stdio transport); the relevant code is unchanged on main as of 2026-07-05, so current alphas (0.143.0-alpha.36) should reproduce.

Root cause

McpElicitationSchema in codex-rs/app-server-protocol/src/protocol/v2/mcp.rs is declared with #[serde(rename_all = "camelCase", deny_unknown_fields)] and only models $schema / type / properties / required — no top-level title.

FastMCP (and any Pydantic-based server) emits the model's auto-generated title at the top level of requestedSchema. Verified against fastmcp 3.4.2 — this is the wire schema for a plain ctx.elicit(..., response_type=str):

{
  "properties": {
    "value": {"title": "Value", "type": "string"}
  },
  "required": ["value"],
  "title": "ScalarElicitationType",
  "type": "object"
}

Serde's deny_unknown_fields hard-fails on "title" before the form is ever shown.

Why this is a Codex bug rather than a FastMCP one

  • The normative MCP schema (2025-11-25 schema.json) does not set additionalProperties: false on ElicitRequestFormParams.requestedSchema, so extra members like title validate against the spec.
  • The official Rust SDK models it: rmcp's elicitation_schema.rs has a top-level title field and does not use deny_unknown_fields.
  • Codex itself is inconsistent about it: the property-level schemas in the same file (McpElicitationStringSchema, McpElicitationNumberSchema, ...) all accept title — only the object level rejects it.

Impact

Any FastMCP-based MCP server using elicitation is unusable with Codex, despite Codex advertising elicitation: {form, url} in the initialize handshake and having a working form UI (#17043). FastMCP puts title there by default with no supported way to suppress it, so this likely affects a large share of Python MCP servers in the wild.

Suggested fix

Either add the field:

#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,

or drop deny_unknown_fields from McpElicitationSchema (matching rmcp and the spec's permissive stance). Either is a one-line change.

Repro

  1. pip install fastmcp (3.4.2), minimal server:

```python
from fastmcp import FastMCP, Context
mcp = FastMCP("probe")

@mcp.tool
async def try_elicitation(ctx: Context) -> str:
result = await ctx.elicit("Reply with any short string.", response_type=str)
return f"ELICIT_{result.action}"

mcp.run()
```

  1. Register it as an MCP server in Codex, call try_elicitation.
  2. Codex logs the unknown field 'title' error; the elicitation fails instead of rendering the form.

View original on GitHub ↗

5 Comments

Pranav188 · 1 month ago

Confirmed the root cause and put together a tested fix on a branch (happy to open a PR if the team would like one, per the invitation-only policy).

Root cause recap: McpElicitationSchema in codex-rs/app-server-protocol/src/protocol/v2/mcp.rs is #[serde(deny_unknown_fields)] but only models $schema/type/properties/required, so FastMCP's top-level title triggers unknown field 'title' before the form renders.

One thing worth flagging: a title-only fix is incomplete. Pydantic also emits a top-level description whenever the elicitation type has a docstring, which hits the identical deny_unknown_fields failure. For example, ctx.elicit("...", response_type=SomeModel) where SomeModel has a docstring produces:

{
  "type": "object",
  "title": "SomeModel",
  "description": "The model's docstring.",
  "properties": { "value": { "title": "Value", "type": "string" } },
  "required": ["value"]
}

So description should be accepted alongside title. Both are already accepted on the per-property schemas in this same file (McpElicitationStringSchema, NumberSchema, BooleanSchema, …) — only the object level rejects them, which is the inconsistency. Both are also spec-valid (the MCP 2025-11-25 requestedSchema does not set additionalProperties: false) and rmcp models a top-level title.

Proposed fix: add optional top-level title and description to McpElicitationSchema, keeping deny_unknown_fields for genuinely unexpected members. Regenerate the TS/JSON schema fixtures and add a regression test using the FastMCP wire shape (fails before, passes after).

I have this implemented on a branch with cargo test -p codex-app-server-protocol (incl. the new regression test), cargo check -p codex-tui, just fmt, and clippy all green: https://github.com/Pranav188/codex/tree/fix/mcp-elicitation-top-level-title-description

Only the object-level fix is needed; the per-property schemas already handle these keywords.

_(Analysis and patch prepared with AI assistance.)_

fugu-ai · 1 month ago

Confirmed your description extension firsthand (fastmcp 3.4.2): a Pydantic elicitation model with a docstring emits a top-level "description" alongside "title" —

{
  "description": "Pick allow or deny for the pending binary.",
  "properties": {"value": {"title": "Value", "type": "string"}},
  "required": ["value"],
  "title": "Choice",
  "type": "object"
}

— so a title-only fix would indeed keep failing for any model with a docstring. Accepting both optional top-level fields while keeping deny_unknown_fields matches the per-property schemas in the same file and stays within the normative requestedSchema (no additionalProperties: false). Your proposed scope looks exactly right from the reporter's side; a regression test on the FastMCP wire shape covers the real-world case that hit this.

chethanuk · 1 month ago

Unable to create PR - I can't create PR to repo... Can someone assist?

Pranav188 · 1 month ago

@chethanuk the contributions are invite only. i fixed it too, but can't create PR

Code-weaver1 · 1 month ago

Some additional analysis for whoever lands the invited fix — there's a second breakage shape hiding behind the reported one.

What's already fine (from source): the per-property structs (McpElicitationStringSchema etc.) do model title and description, so the property-level extras in the wire examples above parse today. The reported failure is top-level only (title, and per @fugu-ai also description).

The next shape: default on non-boolean properties. Pydantic emits default inside the property schema for any field with a default value (verified with pydantic 2.x model_json_schema()):

{
  "description": "Pick allow or deny.",
  "properties": {
    "value": {"default": "allow", "description": "allow or deny", "title": "Value", "type": "string"}
  },
  "title": "ConfirmAction", "type": "object"
}

McpElicitationBooleanSchema models default (faithful to the spec, which only defines it for booleans), but the String/Number/Enum variants don't — and they're all deny_unknown_fields inside the #[serde(untagged)] McpElicitationPrimitiveSchema. So a string property with a default fails every variant and surfaces as the opaque data did not match any variant of untagged enum McpElicitationPrimitiveSchema — after the top-level title/description leniency is added. A fix scoped to the reported error message will look merged-and-done, then break again on the first ctx.elicit model with a field default.

Fix-shape implication: since the normative MCP schema doesn't set additionalProperties: false anywhere under requestedSchema (and rmcp models it leniently), dropping deny_unknown_fields on the elicitation structs aligns with the spec and closes the whole class — as opposed to adding fields one wire-quirk at a time. If stricter parsing is preferred, the minimum set for Pydantic-based servers is: top-level title + description, plus default on the non-boolean primitive variants.

(Caveat: default emission is verified at the Pydantic layer; FastMCP passes title/description through unmodified per this thread, and its schemas come straight from Pydantic, so pass-through of default is expected but I haven't captured it on the wire.)