Skip to content

Let plugin threads actually use MCP servers, and let an operator drop the ones they should not be offered - #111

Merged
patriyang merged 6 commits into
mainfrom
fix/105-109-mcp-elicitation-and-scoping
Aug 11, 2026
Merged

Let plugin threads actually use MCP servers, and let an operator drop the ones they should not be offered#111
patriyang merged 6 commits into
mainfrom
fix/105-109-mcp-elicitation-and-scoping

Conversation

@patriyang

@patriyang patriyang commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Closes #109
Closes #105

Two changes on one surface: whether a Codex thread this plugin starts can use the MCP servers it
is offered, and whether the operator can decide which ones it is offered at all.

#105 was filed as an open policy question with three options and a prerequisite nobody had tested.
Probing a live app-server to settle that prerequisite turned up #109, which changes the premise —
so the two land together.

#109 — every MCP tool call was failing as a phantom user rejection

Reproduced through the shipped entry point, before the fix:

node plugins/codex/scripts/codex-companion.mjs task --json \
  "Call the codegraph MCP status tool once and report the indexed file count..."
→ "failureMessage": "user rejected MCP tool call"

No user was asked. Tracing the JSONL against a raw codex app-server, the app-server sends a
server→client request mcpServer/elicitation/request carrying
_meta.codex_approval_kind: "mcp_tool_call", and AppServerClientBase.handleServerRequest answered
every server request with -32601. The app-server reads that refusal as a denial rather than as
"this client does not implement that method", and reports it to the model as a user rejection.

handleServerRequest now answers elicitations:

  • approval-kind (_meta.codex_approval_kind present) → { action: "accept", content: {}, _meta: null }.
    These threads run approvalPolicy: "never" with nobody at the keyboard; an approval gate has no
    one to answer it.
  • anything else — a real data-collection form, or mode: "url"{ action: "decline", content: null, _meta: null }.
    Fabricating form values would be worse than declining, and a protocol-valid decline still avoids
    the phantom rejection.
  • every other server method keeps -32601, which is the right answer for a method the client
    genuinely does not implement.

Both transports inherit this from the base class, so the broker path is covered too — and in shared
mode the broker is the process that answers, which is worth knowing when testing: a broker started
before this change keeps rejecting until it restarts.

This means MCP tool calls from plugin threads are now approved unattended. That is deliberate rather
than incidental, it is the direction the operator picked, and it is stated plainly in the README —
an MCP tool is not confined by the thread's sandbox the way a shell command is. The knob below is
the control.

#105CODEX_DISABLED_MCP_SERVERS

Comma-separated server names to disable on threads this plugin starts. Unset by default, so nothing
changes for anyone who does not opt in — issue option 1 stays the default, option 2 becomes
available per-server.

Two things worth recording, both established against a live app-server (codex-cli 0.147.0) rather
than inferred from the generated types:

  • The mechanism the issue guessed does not work. config: { mcp_servers: {} } on thread/start
    leaves every server attached — same at the CLI (codex exec -c 'mcp_servers={}').
  • mcp_servers.<name>.enabled = false does work, as a bare dotted key, in both the thread/start
    config map and -c. That is the lever used here.

Names must match [A-Za-z0-9_-]+; anything else is skipped with a warning. That is not fussiness:
-c 'mcp_servers."hermes-vault".enabled=false' fails config loading outright
(invalid transport in mcp_servers."hermes-vault") because the quoted segment is read as a new
server table, so emitting a quoted key would break thread/start for the whole run instead of just
leaving one server enabled. Hyphens are fine unquoted.

Also fixed while in those two functions: buildThreadParams / buildResumeParams only built
params.config when writableRoots was non-empty, silently dropping options.config otherwise.
The map is now built unconditionally and attached only when non-empty — with nothing to say, the
params are byte-identical to before.

Verification

node --test --test-concurrency=1 tests/*.test.mjs354 pass, 0 fail (346 on main; +8 here).

Tests were written first and confirmed red: reverting only app-server.mjs reds the three
elicitation tests, and reverting only codex.mjs reds the disable tests. New coverage asserts the
recorded client reply on the wire (accept / decline / -32601) and the recorded thread/start and
thread/resume config maps, via the fake app-server fixture.

End-to-end against a live Codex and the shipped entry point, same workspace, only the env var
differing:

Run Result
before the fix user rejected MCP tool call
after, env unset CALLED 42 — the tool actually ran
after, CODEX_DISABLED_MCP_SERVERS=codegraph NO_CODEGRAPH — not offered

Left alone

Collision note

#102 was in flight in a parallel worktree while this was being built and landed first as #110.
This branch is rebased on top of it (version bumped to 1.0.46 rather than 1.0.45), and the full
suite was re-run after the rebase: 361 pass, 0 fail. No overlap between the two changes.

@patriyang
patriyang force-pushed the fix/105-109-mcp-elicitation-and-scoping branch from be37dbf to c6c8d87 Compare August 11, 2026 23:41
@patriyang
patriyang merged commit f58c8ad into main Aug 11, 2026
3 checks passed
@patriyang
patriyang deleted the fix/105-109-mcp-elicitation-and-scoping branch August 11, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant