Auto-accept codex elicitation requests + pluggable inbound-request handlers (fix wedge — internal#659 P1#1 part 2) #49

Merged
hongming merged 1 commits from fix/auto-accept-elicitation-requests into main 2026-05-24 10:51:26 +00:00
Owner

Summary

PR #48 fixed the initialize handshake — but a second protocol gap then dominated every codex turn: mcpServer/elicitation/request. Codex 0.130 sends this server-initiated JSON-RPC request to ask permission for MCP tool calls (e.g. inbox_peek). Without a response, the turn wedges and times out at 600s.

This PR makes AppServerProcess._dispatch route server-initiated requests (method + id) properly:

  1. Caller-registered handler (set_inbound_request_handler) if one exists for the method.
  2. Default policy: auto-accept elicitation requests; decline (don't hang) anything else.

Production evidence — 2026-05-24

unrecognized message from app-server: {'method': 'mcpServer/elicitation/request',
  'id': 0, 'params': {'threadId':'019e5981-…', 'serverName':'molecule', 'mode':'form',
  '_meta': {'codex_approval_kind':'mcp_tool_call', 'tool_description':
  'List pending inbound messages without removing them.', ...},
  'message': 'Allow the molecule MCP server to run tool "inbox_peek"?'}}
codex turn 019e5981-... wedged: no events for 90s (deltas=34) — failing turn
codex turn timed out after 600s

CR2 + Researcher post-PR#48 still produced zero agent_log for 15+ minutes because every cron tick hit this loop.

Upstream contract

Per codex-rs/app-server/README.md:

Form elicitations: { "action": "accept", "content": ... }, { "action": "decline", "content": null }, or { "action": "cancel", "content": null }.

For tool-call elicitations specifically the _meta.codex_approval_kind is "mcp_tool_call". Since our MCP server is the in-process molecule adapter (we wrote it, trust it, approvalPolicy:never is already set in thread/start), every elicitation should be auto-accepted.

Fix

  • app_server.py: extend _dispatch with an inbound-request branch. New public set_inbound_request_handler(method, handler) API for callers that want custom per-method policy. Default policy: auto-accept mcpServer/elicitation/request; decline (don't hang) anything else, with a WARN log.
  • tests/mock_app_server.py: new send_inbound_request test RPC that fires a server→client request and relays the client's response back so tests can assert on it.
  • tests/test_app_server.py: 3 new tests:
    • test_inbound_elicitation_request_auto_accepted (regression guard for the CR2/Researcher wire)
    • test_inbound_unknown_request_auto_declined (no-hang policy)
    • test_inbound_request_handler_override (custom handler reaches result)

15/15 tests pass locally.

Test plan

  • pytest tests/test_app_server.py -x -q — 15/15 pass
  • Merge → image build → promote pin → re-provision CR2 + Researcher
  • Verify agent_log count > 0 within one cron tick

Related

🤖 Generated with Claude Code

## Summary PR #48 fixed the initialize handshake — but a second protocol gap then dominated every codex turn: `mcpServer/elicitation/request`. Codex 0.130 sends this server-initiated JSON-RPC request to ask permission for MCP tool calls (e.g. `inbox_peek`). Without a response, the turn wedges and times out at 600s. This PR makes `AppServerProcess._dispatch` route server-initiated **requests** (method + id) properly: 1. Caller-registered handler (`set_inbound_request_handler`) if one exists for the method. 2. Default policy: auto-accept elicitation requests; decline (don't hang) anything else. ## Production evidence — 2026-05-24 ``` unrecognized message from app-server: {'method': 'mcpServer/elicitation/request', 'id': 0, 'params': {'threadId':'019e5981-…', 'serverName':'molecule', 'mode':'form', '_meta': {'codex_approval_kind':'mcp_tool_call', 'tool_description': 'List pending inbound messages without removing them.', ...}, 'message': 'Allow the molecule MCP server to run tool "inbox_peek"?'}} codex turn 019e5981-... wedged: no events for 90s (deltas=34) — failing turn codex turn timed out after 600s ``` CR2 + Researcher post-PR#48 still produced zero `agent_log` for 15+ minutes because every cron tick hit this loop. ## Upstream contract Per `codex-rs/app-server/README.md`: > Form elicitations: `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`. For tool-call elicitations specifically the `_meta.codex_approval_kind` is `"mcp_tool_call"`. Since our MCP server is the in-process `molecule` adapter (we wrote it, trust it, `approvalPolicy:never` is already set in `thread/start`), every elicitation should be auto-accepted. ## Fix - `app_server.py`: extend `_dispatch` with an inbound-request branch. New public `set_inbound_request_handler(method, handler)` API for callers that want custom per-method policy. Default policy: auto-accept `mcpServer/elicitation/request`; decline (don't hang) anything else, with a WARN log. - `tests/mock_app_server.py`: new `send_inbound_request` test RPC that fires a server→client request and relays the client's response back so tests can assert on it. - `tests/test_app_server.py`: 3 new tests: - `test_inbound_elicitation_request_auto_accepted` (regression guard for the CR2/Researcher wire) - `test_inbound_unknown_request_auto_declined` (no-hang policy) - `test_inbound_request_handler_override` (custom handler reaches result) **15/15 tests pass locally.** ## Test plan - [x] `pytest tests/test_app_server.py -x -q` — 15/15 pass - [ ] Merge → image build → promote pin → re-provision CR2 + Researcher - [ ] Verify `agent_log` count > 0 within one cron tick ## Related - internal#659 P1#1 (codex template restart-safety) — this is the second of two protocol gaps; PR#48 was the first - Upstream contract: https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hongming added 1 commit 2026-05-24 10:42:20 +00:00
Auto-accept codex elicitation requests; pluggable inbound-request handlers
CI / Template validation (static) (push) Successful in 31s
CI / Adapter unit tests (push) Successful in 29s
CI / Adapter unit tests (pull_request) Successful in 10s
CI / Template validation (static) (pull_request) Successful in 32s
CI / Template validation (runtime) (push) Successful in 1m53s
CI / Template validation (runtime) (pull_request) Successful in 1m25s
CI / T4 tier-4 conformance (live) (push) Successful in 1m40s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 1m12s
CI / validate (push) Successful in 1s
CI / validate (pull_request) Successful in 1s
cef5337bfb
Codex 0.130 introduced `mcpServer/elicitation/request` — a server-initiated
JSON-RPC request that pauses an active turn and asks the client whether to
allow a configured MCP server to invoke a specific tool. Without a response,
the turn wedges and times out at 600s with `codex turn ... wedged: no events
for 90s (deltas=N) — failing turn`.

Observed live 2026-05-24 on CR2 + Researcher AFTER PR#48 landed:

  unrecognized message from app-server: {'method': 'mcpServer/elicitation/request',
    'id': 0, 'params': {'threadId': '019e5981-...', 'turnId': '019e5981-...',
    'serverName': 'molecule', 'mode': 'form', '_meta': {'codex_approval_kind':
    'mcp_tool_call', 'persist': ['session', 'always'], 'tool_description':
    'List pending inbound messages without removing them.',
    'tool_params': {'limit': 10}, ...}, 'message': 'Allow the molecule MCP
    server to run tool "inbox_peek"?', 'requestedSchema': {...}}}
  codex turn 019e5981-2ea9-73a3-ad4f-104fc0886199 wedged: no events for 90s
    (deltas=34) — failing turn
  codex turn timed out after 600s

PR#48 unblocked the initialize handshake — Registered:200, POST / 200 OK — but
this second wedge then dominated every turn.

Fix: AppServerProcess._dispatch now routes server-initiated REQUESTS (method
+ id) through:

1. A caller-registered handler (set_inbound_request_handler) if one exists
   for the method. Handler is async (method, params) -> result; result becomes
   the JSON-RPC `result` payload. Exceptions get surfaced as JSON-RPC -32000.

2. Default policy for unregistered methods:
   - mcpServer/elicitation/request -> {action: 'accept', content: {}}
     (our MCP server is the in-process molecule adapter — we wrote it,
     we trust it; auto-approving every elicitation matches the
     approvalPolicy=never config codex is started with)
   - everything else -> {action: 'decline', content: null} + WARN log
     (better to decline cleanly than hang the turn)

Both paths use `asyncio.create_task` to keep the reader loop non-blocking
during slow handlers.

Tests:
- mock_app_server.py: new `send_inbound_request` test RPC that fires a
  server→client request and relays the client's response back so tests can
  assert on it. Plus a _pending_inbound futures map routed from the read
  loop when the client's response comes back.
- test_app_server.py: 3 new tests:
    - test_inbound_elicitation_request_auto_accepted (regression guard
      for the exact wire we hit on CR2/Researcher)
    - test_inbound_unknown_request_auto_declined (no-hang policy)
    - test_inbound_request_handler_override (custom handler works,
      receives method+params, return value reaches JSON-RPC result)

15/15 tests pass locally.

This is the second of the two protocol gaps causing codex agents to wedge.
PR#48 fixed the initialize handshake; this fixes the in-turn elicitation
loop. Together they restore codex template productivity end-to-end.

Tracking: internal#659 P1#1 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sdk-lead approved these changes 2026-05-24 10:42:44 +00:00
sdk-lead left a comment
Member

APPROVE.

Second protocol gap — PR#48 fixed initialize handshake, this fixes the in-turn mcpServer/elicitation/request loop that was producing the post-PR#48 wedge symptom "unrecognized message from app-server" + "codex turn ... wedged: no events for 90s".

Review points:

  • _dispatch correctly routes the new (method+id) request case; preserves existing notification and response routing
  • default policy is sensible: auto-accept elicitation (matches approvalPolicy:never thread/start config), decline-loud everything else (no silent hangs)
  • set_inbound_request_handler API is clean: per-method, async handler, returns result; exceptions reach JSON-RPC -32000
  • mock_app_server.send_inbound_request lets us test the FULL round-trip without a real codex binary
  • 15/15 tests pass; CI to run

Resolves internal#659 P1#1 (part 2).

APPROVE. Second protocol gap — PR#48 fixed initialize handshake, this fixes the in-turn mcpServer/elicitation/request loop that was producing the post-PR#48 wedge symptom "unrecognized message from app-server" + "codex turn ... wedged: no events for 90s". Review points: - _dispatch correctly routes the new (method+id) request case; preserves existing notification and response routing - default policy is sensible: auto-accept elicitation (matches approvalPolicy:never thread/start config), decline-loud everything else (no silent hangs) - set_inbound_request_handler API is clean: per-method, async handler, returns result; exceptions reach JSON-RPC -32000 - mock_app_server.send_inbound_request lets us test the FULL round-trip without a real codex binary - 15/15 tests pass; CI to run Resolves internal#659 P1#1 (part 2).
hongming merged commit 3cab9de1ba into main 2026-05-24 10:51:26 +00:00
hongming deleted branch fix/auto-accept-elicitation-requests 2026-05-24 10:51:27 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-ai-workspace-template-codex#49