Skip to content

feat(agent): structured output for codex adapter via stdio MCP#1885

Open
ryans-posthog wants to merge 3 commits intomainfrom
ryan/codex_structured_output
Open

feat(agent): structured output for codex adapter via stdio MCP#1885
ryans-posthog wants to merge 3 commits intomainfrom
ryan/codex_structured_output

Conversation

@ryans-posthog
Copy link
Copy Markdown
Contributor

@ryans-posthog ryans-posthog commented Apr 25, 2026

Problem

The Codex adapter has no equivalent to Claude's outputFormat for structured output. Callers that pass _meta.jsonSchema through the agent server expecting a validated JSON payload back got plain-text completions instead — Codex simply ignored the schema.

Changes

  • New stdio MCP server (packages/agent/src/adapters/codex/structured-output-mcp-server.ts) that registers a single create_output tool whose input shape is generated from the caller-supplied JSON schema. AJV validates each invocation; invalid input is returned as a tool error so the model can retry.
  • CodexAcpAgent.applyStructuredOutput injects this MCP server into newSession / loadSession / unstable_resumeSession / unstable_forkSession whenever _meta.jsonSchema is set and an onStructuredOutput callback is wired. The schema is passed to the child process as a base64-encoded env var to avoid shell escaping. A short system-prompt note is appended via _meta.systemPrompt instructing the model to call create_output with the final result.
  • createCodexClient intercepts tool_call / tool_call_update notifications: it captures rawInput across status transitions and fires onStructuredOutput(...) exactly once per tool-call id when status reaches completed. State is cleared on fire so a re-emitted update can't double-fire.
  • Resolver walks up from the bundled adapter location to find the compiled MCP server script across the various tsup entry points (agent.js, server/bin.cjs, server/harness/bin.js, …). POSTHOG_STRUCTURED_OUTPUT_MCP_SCRIPT overrides the lookup.
  • Adds the script as a tsup entry, plumbs onStructuredOutput through acp-connection.tsCodexAcpAgentcreateCodexClient, and adds @modelcontextprotocol/sdk as a dependency.

How did you test this?

  • Reviewed the diff and confirmed pnpm --filter agent typecheck reports only pre-existing errors in enrichment/file-enricher.ts (same errors are present on main, unrelated to this PR).
  • No unit tests added or run in this PR. The callback state machine in codex-client.ts (one-shot fire across tool_call + tool_call_update) and applyStructuredOutput's MCP-server / system-prompt injection are both unit-testable and would be worth covering in a follow-up — the existing codex-client.test.ts already mocks the upstream connection and is the natural place to add a test.

Codex has no native equivalent of Claude's outputFormat. When a task has
json_schema + an onStructuredOutput callback, inject an in-process stdio
MCP server exposing a create_output tool that validates against the
schema with AJV, and watch for a completed tool_call_update on the ACP
stream to fire onStructuredOutput exactly once.

Path resolution walks up from import.meta.dirname so the compiled MCP
script is found regardless of which bundle entry imports this module
(dist/agent.js, dist/server/bin.cjs, harness bundles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

ryans-posthog commented Apr 25, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 25, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/codex-client.ts
Line: 63

Comment:
**Permissive `includes` match can collide with user-defined tools**

`title.includes("create_output")` will match any tool whose name contains that substring (e.g. `my_create_output`, `mcp__vendor__create_output_report`, etc.). When codex-acp uses the prefixed form `mcp__posthog_output__create_output`, the server name `posthog_output` is available in the title too. A more defensive check would also require the known server name to be present so an unrelated user tool named `create_output` doesn't silently trigger `onStructuredOutput` with the wrong payload.

```suggestion
  return (
    title.includes("posthog_output") && title.includes(STRUCTURED_OUTPUT_TOOL_NAME)
  ) || title === STRUCTURED_OUTPUT_TOOL_NAME;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/codex-client.ts
Line: 53

Comment:
**`STRUCTURED_OUTPUT_TOOL_NAME` duplicated across three files**

`"create_output"` is declared as `STRUCTURED_OUTPUT_TOOL_NAME` here, as `STRUCTURED_OUTPUT_TOOL_NAME` (exported) in `codex-agent.ts`, and as `OUTPUT_TOOL_NAME` in `structured-output-mcp-server.ts`. This is a OnceAndOnlyOnce violation — a single constant change now requires three edits. `codex-client.ts` could import the exported constant from `codex-agent.ts`, and the MCP server (which is a separate bundle entry) can either duplicate it or be refactored to share a tiny constants file.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/structured-output-mcp-server.ts
Line: 51-64

Comment:
**Redundant AJV validation — Zod already validates before the handler is called**

`McpServer.tool()` parses and validates the incoming arguments against the Zod schema before invoking the handler, so by the time `validate(args)` runs, AJV re-validates an already-valid object. If the motivation is richer error messages, the Zod errors are available via `safeParse`. The AJV pass is a superfluous part that doubles the schema compilation cost on every server start and adds an extra dependency path.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/structured-output-mcp-server.ts
Line: 19-37

Comment:
**Silent `process.exit(1)` calls leave no diagnostic trace**

All three early-exit paths exit without printing anything to stderr. If codex-acp spawns this server and it immediately dies with exit code 1, the parent has no way to distinguish a missing env var from a bad schema or an unsupported schema type. Even a single `process.stderr.write(...)` line before each exit would make failures much easier to diagnose in production.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(agent): structured output for codex..." | Re-trigger Greptile

*/
function isStructuredOutputToolCall(title: string | undefined | null): boolean {
if (!title) return false;
return title.includes(STRUCTURED_OUTPUT_TOOL_NAME);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Permissive includes match can collide with user-defined tools

title.includes("create_output") will match any tool whose name contains that substring (e.g. my_create_output, mcp__vendor__create_output_report, etc.). When codex-acp uses the prefixed form mcp__posthog_output__create_output, the server name posthog_output is available in the title too. A more defensive check would also require the known server name to be present so an unrelated user tool named create_output doesn't silently trigger onStructuredOutput with the wrong payload.

Suggested change
return title.includes(STRUCTURED_OUTPUT_TOOL_NAME);
return (
title.includes("posthog_output") && title.includes(STRUCTURED_OUTPUT_TOOL_NAME)
) || title === STRUCTURED_OUTPUT_TOOL_NAME;
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/codex-client.ts
Line: 63

Comment:
**Permissive `includes` match can collide with user-defined tools**

`title.includes("create_output")` will match any tool whose name contains that substring (e.g. `my_create_output`, `mcp__vendor__create_output_report`, etc.). When codex-acp uses the prefixed form `mcp__posthog_output__create_output`, the server name `posthog_output` is available in the title too. A more defensive check would also require the known server name to be present so an unrelated user tool named `create_output` doesn't silently trigger `onStructuredOutput` with the wrong payload.

```suggestion
  return (
    title.includes("posthog_output") && title.includes(STRUCTURED_OUTPUT_TOOL_NAME)
  ) || title === STRUCTURED_OUTPUT_TOOL_NAME;
```

How can I resolve this? If you propose a fix, please make it concise.

onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
}

const STRUCTURED_OUTPUT_TOOL_NAME = "create_output";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 STRUCTURED_OUTPUT_TOOL_NAME duplicated across three files

"create_output" is declared as STRUCTURED_OUTPUT_TOOL_NAME here, as STRUCTURED_OUTPUT_TOOL_NAME (exported) in codex-agent.ts, and as OUTPUT_TOOL_NAME in structured-output-mcp-server.ts. This is a OnceAndOnlyOnce violation — a single constant change now requires three edits. codex-client.ts could import the exported constant from codex-agent.ts, and the MCP server (which is a separate bundle entry) can either duplicate it or be refactored to share a tiny constants file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/codex-client.ts
Line: 53

Comment:
**`STRUCTURED_OUTPUT_TOOL_NAME` duplicated across three files**

`"create_output"` is declared as `STRUCTURED_OUTPUT_TOOL_NAME` here, as `STRUCTURED_OUTPUT_TOOL_NAME` (exported) in `codex-agent.ts`, and as `OUTPUT_TOOL_NAME` in `structured-output-mcp-server.ts`. This is a OnceAndOnlyOnce violation — a single constant change now requires three edits. `codex-client.ts` could import the exported constant from `codex-agent.ts`, and the MCP server (which is a separate bundle entry) can either duplicate it or be refactored to share a tiny constants file.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +51 to +64
const valid = validate(args);
if (!valid) {
const errors = validate.errors
?.map((e) => `${e.instancePath || "/"}: ${e.message}`)
.join("; ");
return {
content: [
{
type: "text" as const,
text: `Validation failed: ${errors}. Please fix the output and try again.`,
},
],
isError: true,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant AJV validation — Zod already validates before the handler is called

McpServer.tool() parses and validates the incoming arguments against the Zod schema before invoking the handler, so by the time validate(args) runs, AJV re-validates an already-valid object. If the motivation is richer error messages, the Zod errors are available via safeParse. The AJV pass is a superfluous part that doubles the schema compilation cost on every server start and adds an extra dependency path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/structured-output-mcp-server.ts
Line: 51-64

Comment:
**Redundant AJV validation — Zod already validates before the handler is called**

`McpServer.tool()` parses and validates the incoming arguments against the Zod schema before invoking the handler, so by the time `validate(args)` runs, AJV re-validates an already-valid object. If the motivation is richer error messages, the Zod errors are available via `safeParse`. The AJV pass is a superfluous part that doubles the schema compilation cost on every server start and adds an extra dependency path.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +19 to +37
const schemaEnv = process.env.POSTHOG_OUTPUT_SCHEMA;
if (!schemaEnv) {
process.exit(1);
}

let jsonSchema: Record<string, unknown>;
try {
jsonSchema = JSON.parse(Buffer.from(schemaEnv, "base64").toString("utf-8"));
} catch (_err) {
process.exit(1);
}

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(jsonSchema);

const zodType = z.fromJSONSchema(jsonSchema);
if (!(zodType instanceof z.ZodObject)) {
process.exit(1);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent process.exit(1) calls leave no diagnostic trace

All three early-exit paths exit without printing anything to stderr. If codex-acp spawns this server and it immediately dies with exit code 1, the parent has no way to distinguish a missing env var from a bad schema or an unsupported schema type. Even a single process.stderr.write(...) line before each exit would make failures much easier to diagnose in production.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/codex/structured-output-mcp-server.ts
Line: 19-37

Comment:
**Silent `process.exit(1)` calls leave no diagnostic trace**

All three early-exit paths exit without printing anything to stderr. If codex-acp spawns this server and it immediately dies with exit code 1, the parent has no way to distinguish a missing env var from a bad schema or an unsupported schema type. Even a single `process.stderr.write(...)` line before each exit would make failures much easier to diagnose in production.

How can I resolve this? If you propose a fix, please make it concise.

ryans-posthog and others added 2 commits April 26, 2026 15:30
- Require both server and tool name when matching prefixed tool titles so
  unrelated user tools containing "create_output" can't trigger
  onStructuredOutput.
- Hoist STRUCTURED_OUTPUT_MCP_NAME / STRUCTURED_OUTPUT_TOOL_NAME into a
  shared constants module so codex-agent, codex-client, and the spawned
  MCP server stay in sync.
- Drop redundant AJV pass in the MCP server — McpServer.tool() already
  validates against the Zod shape derived from the schema.
- Print a diagnostic to stderr before each early process.exit(1) so
  spawn failures aren't silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ryans-posthog ryans-posthog requested review from a team and tatoalo April 28, 2026 17:21
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