Skip to content

feat(codex): native Codex plugin — annotate a document, feedback returns to Codex - #1635

Draft
backnotprop wants to merge 4 commits into
mainfrom
feat/codex-plugin-annotate-mcp
Draft

backnotprop wants to merge 4 commits into
mainfrom
feat/codex-plugin-annotate-mcp

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

First native Codex plugin flow: annotate a document and send the feedback to Codex. Plan review via submit_plan is out of scope for this PR.

What's in it

  • plannotator mcp (apps/hook/server/mcp-*.ts): a stdio MCP server. It is hand-rolled JSON-RPC because @modelcontextprotocol/sdk is not a dependency, and this keeps the compiled binary's footprint at zero. stdout carries only the protocol: console.log is redirected to stderr in this mode.
    • Tool annotate { target, cwd?, markdown?, noJina?, gate? }. It resolves the target with the CLI's own resolveAnnotateTarget (file, folder, URL, live app), starts the annotate server, opens the browser, and blocks until the human decides.
    • The result text is byte-for-byte the plaintext CLI output (feedback, or The user approved.). Where the CLI prints nothing, it returns an explicit sentence instead: The user closed Plannotator without sending feedback., and a separate sentence for an empty submit. structuredContent is the --json record.
    • notifications/cancelled or stdin EOF stops the annotate server and sends no result.
    • The session URL goes to stderr (the same ready handler as the CLI), an MCP log notification, and a progress notification.
  • apps/codex-plugin/ contains .codex-plugin/plugin.json, .mcp.json, the plannotator-annotate skill, a local marketplace (.agents/plugins/marketplace.json, source ./), and a README with install steps.
  • Desktop view spike: ui://plannotator/annotate, one self-contained HTML string (about 8 KB), not the app. It polls an app-only status tool through host-proxied tools/call, has an Open Plannotator button (ui/open-link), and shows the state. It posts the feedback with ui/message only when the session finished and no ui/notifications/tool-result arrived within 5 seconds. A spec-following host therefore never gets the feedback twice, and when the view is not rendered, the tool result is unchanged.
  • Docs: a CLAUDE.md section, a pointer in apps/codex/README.md, and plannotator mcp in the knowledge skill (required by the freshness test).
  • The plugin's plugin.json version is added to the release version guard.

Codex facts this relies on (checked in Codex source at 26dd19ef47 and against codex-cli 0.157.1)

Question Answer
Is tool_timeout_sec honored in a plugin .mcp.json? Yes. Plugin server JSON deserializes straight into McpServerConfig. The default is 300 s; set here to 345600, matching the Stop hook.
What cwd does Codex give a plugin MCP server? The session's working directory (-C), not the Codex process cwd. I measured this with lsof while Codex ran from / with -C /tmp/pn-e2e/work: the server cwd was /private/tmp/pn-e2e/work, and the relative target notes.md resolved.
Environment Codex allowlists MCP server env (HOME PATH SHELL USER LANG TERM TMPDIR ...). Without env_vars, PLANNOTATOR_REMOTE, SSH_CONNECTION, PLANNOTATOR_PORT and PLANNOTATOR_DATA_DIR never reach the server, and remote detection fails silently. .mcp.json lists them. Verified: with PLANNOTATOR_REMOTE=1 PLANNOTATOR_PORT=19555 exported, the session came up on :19555.
Approval prompts readOnlyHint: true lets Codex's default auto mode run the tool without prompting. None appeared in codex exec.
Remote URL visibility Codex only logs MCP progress and log notifications and server stderr, at INFO level. Its TUI renders URL-mode elicitations only for https URLs (validate_external_url), so that cannot surface http://localhost:19432 either. A remote user gets the URL from plannotator sessions on the remote host. This is documented, but still a gap (see follow-ups).
Skill naming Plugin skills are namespaced: the model sees plannotator:plannotator-annotate.

Verification

Unit tests (mcp-annotate.test.ts, mcp-protocol.test.ts) use a temp PLANNOTATOR_DATA_DIR, set and restored per test. They cover:

  • relative targets, explicit cwd, folder, missing file (the error names the root), and bad input, all through the real resolver;
  • all four result shapes;
  • a remote notification;
  • cancellation via the signal, via notifications/cancelled, and via transport shutdown (server stopped, no response sent);
  • ping answered while an annotate call is blocked;
  • the status tool hidden from the model;
  • the ui:// resource MIME type.

bun test apps/hook/server scripts/check-release-version.test.ts: 357 pass, 0 fail, including the skill-reference freshness test.

The full bun test run has 60 to 61 failures. The same suites fail on a clean main in this environment: Pi vendoring parity (needs vendor.sh), background remote discovery, and PLANNOTATOR_AI / Pi runtime.

Real end to end with the Codex CLI (codex-cli 0.157.1), isolated CODEX_HOME, dev binary first on PATH:

export CODEX_HOME=/tmp/pn-e2e/codex-home   # auth.json copied, nothing else
codex plugin marketplace add <repo>/apps/codex-plugin
codex plugin add plannotator@plannotator-local   # -> installed, enabled 0.27.22
PATH=/tmp/pn-e2e/bin:$PATH PLANNOTATOR_DATA_DIR=/tmp/pn-e2e/data PLANNOTATOR_SKIP_BROWSER_OPEN=1 \
  codex exec --json --skip-git-repo-check -s read-only -C /tmp/pn-e2e/work "Use the Plannotator plugin to annotate notes.md ..."
# harness reads the session URL from $PLANNOTATOR_DATA_DIR/sessions/*.json and POSTs /api/feedback
Run Codex tool call Tool result Codex received Model reply
File annotate {"target":"/tmp/pn-e2e/work/notes.md"} {"content":[{"type":"text","text":"# File Feedback ... E2E-MARKER-FILE-7731"}],"structured_content":{"decision":"annotated",...}} Echoed the feedback verbatim
Folder annotate {"target":"/tmp/pn-e2e/work/docs"} (server mode: annotate-folder) ... E2E-MARKER-FOLDER-4410, decision: annotated Echoed verbatim
Relative target, Codex started from / annotate {"target":"notes.md"} Relative OK. E2E-MARKER-CWD-2290 Echoed verbatim
Remote env PLANNOTATOR_REMOTE=1 PLANNOTATOR_PORT=19555 Session on http://localhost:19555, feedback returned Echoed verbatim

Real binary, raw MCP client (one long-lived process, four sequential sessions):

  • file feedback returns first;
  • folder returns second (folder);
  • gate: true plus /api/approve returns The user approved. / {decision:"approved"};
  • /api/exit returns The user closed Plannotator without sending feedback. / {decision:"dismissed"}.

Cancel: after notifications/cancelled, no response was sent for the call, the port refuses new connections, the process keeps serving ping, and stdin EOF exits with code 0.

ui:// view, simulated MCP Apps host in headless Chromium (sandboxed srcdoc iframe, real plannotator mcp behind the proxied tools/call):

  • Tool result delivered: the view shows Waiting for your review and the URL. Open sends ui/open-link {url}. After the decision the view shows Feedback sent / Delivered to Codex as the tool result. No ui/message.
  • Tool result never delivered: the view posts ui/message {role:"user", content:[{type:"text", text:"Plannotator feedback on notes.md:\n\nScenario B feedback"}]} and shows Feedback posted.

Not verified

  • Rendering inside the Codex desktop app. It is gated by the server-side enable_mcp_apps experiment, and I did not touch the real ~/.codex or the desktop app. The view is checked only against my simulated host, which follows the MCP Apps 2026-01-26 spec and the OpenAI extensions doc. Codex's exact host behavior is unverified: whether it proxies tools/call for an app-only tool, when it sends tool-input and tool-result relative to a blocking call, how ui/open-link handles localhost, and how the card sizes.
  • The TUI (interactive) path. codex exec was used throughout. The TUI uses the same MCP client, so the tool result should be identical.
  • Linux, Windows, and a real SSH box. Remote was verified only through env passthrough plus PLANNOTATOR_REMOTE=1.
  • The Codex desktop PATH. .mcp.json runs plannotator from PATH, and app-launched processes may not see ~/.local/bin. This is the same caveat the Stop hook README already gives. The workaround is to edit the cached .mcp.json to an absolute path.

Coexistence with the existing Codex integration

scripts/install.* is untouched, so the installer still writes the Codex Stop hook (plan review) and the core skills under ~/.agents/skills. Those skills shell out to plannotator annotate/review/last. They can live alongside this plugin: the plugin skill is namespaced plannotator:plannotator-annotate, and its tool is annotate on the plannotator MCP server. Suggested installer follow-up:

  • when Codex is detected, codex plugin marketplace add plus codex plugin add;
  • stop installing the core plannotator-annotate skill into the Codex scope, so the model has one annotate path;
  • keep the Stop hook until submit_plan lands.

A repo-root .agents/plugins/marketplace.json would also make codex plugin marketplace add backnotprop/plannotator work directly. I left the repo root alone because Codex reads .agents/... ahead of the existing .claude-plugin/marketplace.json.

Known gaps / follow-ups

  • Remote URL in the Codex UI (see the table). Options: a form-mode elicitation carrying the URL, or --tailscale (https) support in mcp so a URL elicitation passes the TUI's https check.
  • After a cancel, a keep-alive socket from an already-open tab can still reach the stopped server. The listener is closed. The CLI never hits this because its process exits. The fix would be server.stop(true) in the shared annotate server; I did not change shared shutdown in this PR.
  • approvalNotesSupported and the abandoned-tab client lease stay off, matching plaintext plannotator annotate.

Adds `plannotator mcp`, a dependency-free stdio MCP server whose `annotate`
tool resolves a file, folder, or URL exactly like `plannotator annotate`,
opens the annotate UI, blocks until the human decides, and returns the
plaintext CLI output as the tool result (plus the --json record as
structuredContent). Cancellation or stdin EOF stops the session.

apps/codex-plugin packages it for Codex: plugin manifest, .mcp.json
(tool_timeout_sec raised, PLANNOTATOR_*/SSH env passthrough, since Codex
allowlists MCP server env), a plannotator-annotate skill, and a local
marketplace. Also ships a small MCP Apps view (ui://plannotator/annotate)
for the Codex desktop app, which only posts feedback via ui/message when
the host never delivered the tool result.
… ends

plannotator mcp outlives each annotate session, so a non-forced Bun
server.stop() left the browser tab's SSE stream and keep-alive sockets
(with their heartbeat timer) open after every decision or cancel. Add an
optional closeActiveConnections flag to the annotate server's stop() and
pass it from the MCP session handle; the CLI and OpenCode callers keep
the old behavior.
…h env_vars

Codex spawns MCP servers with an allowlisted environment, so a user's
PLANNOTATOR_AI=disabled (and the agent-terminal remote opt-in, Glimpse
size, file-browser limit) never reached plannotator mcp.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant