Repository navigation
fix(responses): warn once per session on eagerly expanded Codex MCP catalogs #8133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lidge-jun
merged 11 commits into
lidge-jun:dev
from
atinseau:fix/codex-eager-tool-catalog
Nov 12, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
4999655
fix(responses): reject eagerly expanded Codex MCP catalogs
atinseau a28e9e6
fix(responses): preserve compaction history and narrow Codex detection
atinseau 37cfccd
fix(responses): diagnose eager Codex MCP catalogs without refusing them
lidge-jun 0de3b6a
fix(responses): count Codex catalogs in the real Responses Lite layout
lidge-jun 019bc89
fix(responses): keep the eager catalog diagnostic bounded and non-fatal
lidge-jun 1c5500f
Merge origin/dev into fix/codex-eager-tool-catalog
4679c29
fix(responses): count the catalog each Codex request actually carries
acb10f0
Merge remote-tracking branch 'origin/dev' into fix/codex-eager-tool-c…
9e4d8a0
fix(responses): count a client-resent catalog under previous_response_id
lidge-jun a7ab21c
refactor(responses): keep state.ts within its file-size cap
lidge-jun 338f04b
fix(responses): count MCP tools in the functions group and refresh an…
lidge-jun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| import { currentTurnWireToolCatalogBody } from "../responses-undeclared-tool-guard"; | ||
| import { sessionLaneIdFromRequest } from "../request-log-conversation"; | ||
| import { codeModeRenderedToolNames } from "./code-mode-collab-surface"; | ||
|
|
||
| /** | ||
| * Eager MCP definitions at which a Codex request is diagnosed as carrying an expanded catalog. | ||
| * Routed catalog rows advertise deferred discovery, so a Codex client with current model metadata | ||
| * keeps MCP tools deferred; one whose metadata is missing or stale expands every MCP tool into the | ||
| * request. The request is still forwarded unchanged: this is a diagnostic, never an admission | ||
| * rule, because an eager catalog stays a valid request (ADR-0022). | ||
| */ | ||
| export const EAGER_CODEX_MCP_TOOL_WARNING_THRESHOLD = 64; | ||
| /** | ||
| * Sessions already warned, least recently sent first. Bounded so a long-lived proxy cannot grow | ||
| * it without limit: the dedupe covers this many sessions and no more, so an operator running more | ||
| * sessions than that can see one repeat warning. Recency refreshes on every request a session | ||
| * sends, so the session being evicted is the one that has been idle longest. | ||
| */ | ||
| export const WARNED_SESSION_LIMIT = 1024; | ||
| const warnedSessions = new Map<string, true>(); | ||
| /** Logical requests already warned without a session identity; weak so completed requests leave no trace. */ | ||
| const warnedAnonymousRequests = new WeakSet<object>(); | ||
| const CODEX_CLIENT_IDENTITY = /^(?:codex|codex_cli_rs|codex_app|codex_work_desktop|codexless_agent|Codex Desktop)(?:\/|$)/i; | ||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
|
|
||
| /** | ||
| * Which wire surface a request is being counted for. Compaction sends its history as `input` rather | ||
| * than as declarations, so only its top-level `tools` are this request's catalog. | ||
| */ | ||
| export function eagerCodexToolCatalogSurface( | ||
| body: unknown, | ||
| compactionRecoveryKind: string | undefined, | ||
| ): "responses" | "compact" { | ||
| if (compactionRecoveryKind === "compaction-v1") return "compact"; | ||
| const input = isRecord(body) && Array.isArray(body.input) ? body.input : []; | ||
| return input.some(item => isRecord(item) && item.type === "compaction_trigger") ? "compact" : "responses"; | ||
| } | ||
|
|
||
| function collectEagerMcpTools(specs: unknown[], names: Set<string>, namespace?: string): void { | ||
| for (const spec of specs) { | ||
| if (!isRecord(spec) || spec.defer_loading === true) continue; | ||
| // Codex namespaces are one level deep. A namespace nested inside another is not a Codex | ||
| // declaration, and recursing into caller-shaped depth could exhaust the stack. | ||
| if (spec.type === "namespace" && typeof spec.name === "string" && Array.isArray(spec.tools)) { | ||
| if (namespace === undefined) collectEagerMcpTools(spec.tools, names, spec.name); | ||
| continue; | ||
| } | ||
| const tool = isRecord(spec.function) ? spec.function : spec; | ||
| if (tool.defer_loading === true || typeof tool.name !== "string") continue; | ||
| // `functions` is the reserved unnamespaced group (as in the undeclared-tool guard and parser), | ||
| // so an MCP tool declared inside it keeps its own `mcp__` name. | ||
| const name = namespace && namespace !== "functions" ? `${namespace}__${tool.name}` : tool.name; | ||
| if (name.startsWith("mcp__")) names.add(name); | ||
| // Code Mode renders each enabled, non-deferred nested tool into the exec description. Codex | ||
| // sends exec bare or, on Responses Lite, inside its `functions` namespace. | ||
| if ((namespace === undefined || namespace === "functions") && spec.type === "custom") { | ||
| for (const nested of codeModeRenderedToolNames(tool.name, tool.description)) { | ||
| if (nested.startsWith("mcp__")) names.add(nested); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Responses Lite `additional_tools` the client sent for this request. | ||
| * | ||
| * Codex does not express "current turn" by position: a window resends its base catalog at the head | ||
| * of `input` on every request and appends an incremental `additional_tools` update wherever the | ||
| * change happened, including after a replayed tool call. Both are live declarations that this | ||
| * request pays for, so every client-sent item counts. Only what the proxy itself replayed from | ||
| * stored continuation state is history, and `currentTurnWireToolCatalogBody` removes it before | ||
| * this runs. | ||
| */ | ||
| function clientSentAdditionalTools(input: unknown[]): unknown[][] { | ||
| const groups: unknown[][] = []; | ||
| for (const item of input) { | ||
| if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) groups.push(item.tools); | ||
| } | ||
| return groups; | ||
| } | ||
|
|
||
| /** | ||
| * Eager MCP definitions a canonical Codex client declared for this request, or 0 for any other | ||
| * client. `replayPrefixLength` is the leading input the proxy restored from stored continuation | ||
| * state; those items are history and grant no declaration for this turn. Compaction reads only | ||
| * top-level `tools`: its `input` is history. | ||
| */ | ||
| export function eagerCodexMcpToolCount( | ||
| body: unknown, | ||
| headers: Headers, | ||
| surface: "responses" | "compact" = "responses", | ||
| replayPrefixLength?: number, | ||
| ): number { | ||
| if (!headers.has("x-codex-turn-metadata") | ||
| && !CODEX_CLIENT_IDENTITY.test(headers.get("originator") ?? "") | ||
| && !CODEX_CLIENT_IDENTITY.test(headers.get("user-agent") ?? "")) return 0; | ||
| const current = currentTurnWireToolCatalogBody(body, replayPrefixLength); | ||
| if (!isRecord(current)) return 0; | ||
| const names = new Set<string>(); | ||
| if (Array.isArray(current.tools)) collectEagerMcpTools(current.tools, names); | ||
| if (surface === "responses" && Array.isArray(current.input)) { | ||
| for (const tools of clientSentAdditionalTools(current.input)) collectEagerMcpTools(tools, names); | ||
| } | ||
| return names.size; | ||
| } | ||
|
|
||
| /** | ||
| * Warn once per session when a Codex request carries an eagerly expanded MCP catalog. Only the | ||
| * count and the threshold are logged; tool names, bodies and session identifiers are not. | ||
| * | ||
| * A request without a session identity has no session to warn once per, so the | ||
| * `logicalRequest` scope stands in for one: each logical request warns, and a retry, combo child | ||
| * or compaction replay that shares the scope does not repeat it. Callers that omit the scope fall | ||
| * back to the session only. | ||
| */ | ||
| export function warnEagerCodexToolCatalog( | ||
| body: unknown, | ||
| headers: Headers, | ||
| options: { surface?: "responses" | "compact"; replayPrefixLength?: number; logicalRequest?: object } = {}, | ||
| ): void { | ||
| const surface = options.surface ?? "responses"; | ||
| const session = sessionLaneIdFromRequest(headers); | ||
| if (session !== undefined && warnedSessions.has(session)) { | ||
| // Refresh recency on every request the session sends, eager or not, so an active session is | ||
| // never the one evicted next. An already-warned session needs no count at all. | ||
| warnedSessions.delete(session); | ||
| warnedSessions.set(session, true); | ||
| return; | ||
| } | ||
| // A diagnostic must never change the request it observes: any failure while counting a | ||
| // malformed body skips the warning and lets the request continue exactly as before. | ||
| let count: number; | ||
| try { count = eagerCodexMcpToolCount(body, headers, surface, options.replayPrefixLength); } catch { return; } | ||
| if (count < EAGER_CODEX_MCP_TOOL_WARNING_THRESHOLD) return; | ||
| if (session !== undefined) { | ||
| while (warnedSessions.size >= WARNED_SESSION_LIMIT) { | ||
| const oldest = warnedSessions.keys().next().value; | ||
| if (oldest === undefined) break; | ||
| warnedSessions.delete(oldest); | ||
| } | ||
| warnedSessions.set(session, true); | ||
| } else if (options.logicalRequest !== undefined) { | ||
| if (warnedAnonymousRequests.has(options.logicalRequest)) return; | ||
| warnedAnonymousRequests.add(options.logicalRequest); | ||
| } | ||
| console.warn( | ||
| `[codex-catalog] Codex sent ${count} eager MCP tool definitions (warning threshold ${EAGER_CODEX_MCP_TOOL_WARNING_THRESHOLD}) ` | ||
| + "instead of deferred tool discovery; its model metadata may be missing or stale. The request was forwarded unchanged. " | ||
| + "Restart Codex to reload the catalog ('ocx system codex-restart --yes'; it interrupts running turns). " | ||
| + "Shown once per session, or once per request " | ||
| + "when the client sends no session identity.", | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a canonical Codex request whose expanded
exec.descriptionexceeds 1,048,576 characters,renderedNestedTools()immediately returns[]because of its existingMAX_DESCRIPTION_LENGTHguard. Reusing it here therefore makeseagerCodexMcpToolCount()report zero: a dependency-free probe with 64 complete rendered MCP sections counted 64 below that limit and zero above it. These oversized catalogs are precisely the payload-inflation case this diagnostic is intended to catch, yet they receive no warning. Use a bounded counting strategy that can diagnose valid catalogs above this limit, and add regression coverage for that boundary.Useful? React with 👍 / 👎.