[Bug]: Restore MCP app interactions after a Codex conversation unloads#29433

opened8d agobyPpasssy

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

Use the standalone sample MCP embedded below. It provides a rating card and persists its value to a disposable JSON file.

  1. Configure the sample server in the Codex home used by T3.
  2. Display show_rating in a Codex conversation and save a rating.
  3. Restart T3's host/server while no agent work is running.
  4. Run a short turn in a separate conversation on the same Codex provider.
  5. Reopen the original conversation without sending a message, then click another rating.

The original failure and matching host trace were observed with a custom rating card. These steps with the standalone sample have not been run interactively in T3.

The sample's node verify.mjs independently reproduces the underlying lifecycle failure using the actual Codex app-server, an isolated temporary home, and no model-generation requests.

Expected behavior

An existing MCP app card remains usable after its Codex conversation unloads. Interacting with it restores MCP access without requiring another chat message.

Actual behavior

The card renders, but clicking a rating fails with:

text
1Not saved: The app's MCP server request failed.

Tool discovery fails before the mutation reaches the MCP server, so the rating is not saved. T3 retains the card and native conversation ID, but the restarted Codex process has not loaded that conversation.

Related: #18545 includes the same generic request error; its merged fix addresses unwanted fullscreen entry rather than missing-conversation recovery.

Impact

Major degradation or frequent failure

Version or commit

T3 Code Beta 0.1.6, commit 2359643cffa6.

Environment

macOS desktop, Codex provider. Independent protocol reproduction: codex-cli 0.165.0, Node.js 26.12.1.

Logs or stack traces

Observed host error chain, with the native ID redacted:

shell
1McpAppRequests.toolInfo
2 -> CodexAdapterV2.listTools
3 -> mcpServerStatus/list
4 -> thread not found: <native-thread-id>

The independent sample confirms:

text
1Fresh conversation: app resource loads and rating saves.
2After Codex restart: discovery and rating calls return -32600 thread not found.
3Failed rating call: saved value remains unchanged.
4After thread/resume: discovery and rating saves succeed.
5After another restart and resume: saved rating is retained.

Screenshots, recordings, or supporting files

The complete reproduction is embedded here. Save server.mjs and verify.mjs in the same directory and run node verify.mjs. No npm dependencies, credentials, or other MCP servers are required.

Setup and manual reproduction (README.md)

MCP app rating lifecycle reproduction

A small stdio MCP server with one embedded card, two rating buttons and a JSON state file. No npm packages, credentials, network access or another MCP server are needed. Requires Node.js 22+; the automated verification also requires the Codex CLI. Tested with Node.js 26.12.1 and codex-cli 0.165.0.

What is included

  • server.mjs: the MCP server and its inline MCP Apps HTML resource.
  • verify.mjs: an isolated, model-free Codex app-server lifecycle check.

The two tools are:

  • show_rating: read-only; returns the saved rating and opens the card.
  • set_rating: app-only mutation; saves up or down to a JSON file.

The card makes a single tool request per click, has no polling and displays Saved or the host's failure message. A normal host approval dialog for set_rating is expected; approve it when testing a save.

Verify the underlying protocol failure

sh
1node verify.mjs

This creates a temporary Codex home and state file. It starts and stops only its own child processes and removes its temporary files afterward. It does not alter your installed MCP configuration, existing conversations or ratings. It sends no turn/start or model-generation request. thread/inject_items persists only a synthetic message so that the new conversation can be resumed after restart.

The verifier checks:

  1. A fresh conversation can load the app resource and save a rating.
  2. After the Codex process restarts, both thread-scoped tool discovery and a rating call return -32600: thread not found: <threadId>.
  3. The failed request does not modify the state file.
  4. thread/resume with excludeTurns: true restores discovery and rating saves.
  5. The saved rating survives another process restart.

This verifies the lifecycle failure and recovery in the actual Codex app-server. It does not run T3's UI, or prove the proposed T3 source patch end-to-end.

Try the sample in T3 Code

Add this server to the Codex home used by the selected T3 provider. Replace the paths with absolute paths on your machine. Choose a disposable state file; its parent directory must already exist.

toml
1[mcp_servers.rating-repro]
2command = "node"
3args = ["/absolute/path/to/mcp-rating-repro/server.mjs"]
4
5[mcp_servers.rating-repro.env]
6MCP_REPRO_STATE = "/absolute/path/to/rating-repro-state.json"

Then:

  1. Restart the agent session to load the new MCP configuration. In a new Codex thread, ask: Call rating-repro's show_rating tool once to display its card.
  2. Click Thumbs up, approve the normal tool confirmation if shown, and check that the card says Saved and the JSON file contains "rating":"up".
  3. Restart T3's host/server while no agent work is running. Start a separate Codex conversation on the same provider and let a short turn finish, so its shared app-server process is running. Reopen the original thread without sending a new message that would resume its Codex conversation.
  4. Click Thumbs down in the existing card. On an affected build it can fail with Not saved: The app's MCP server request failed. The state file remains up because the mutation never reaches this MCP server.
  5. Inspect the host trace for McpAppRequests.toolInfo → CodexAdapterV2.listTools → mcpServerStatus/list → thread not found.

These T3 UI steps are a proposed reproduction using this sample. They have not been run interactively with this sample. The actual UI failure and matching host trace were observed with a custom rating card; the independent verification above uses only this standalone server.

No fix is applied to T3 by running either script. To remove the sample, remove its MCP configuration and delete its disposable state file.

MCP server and inline rating card (server.mjs)
javascript
1// Minimal MCP Apps reproduction. Node.js only; no dependencies or network.
2import { createInterface } from "node:readline";
3import { readFile, writeFile } from "node:fs/promises";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6
7const stateFile = process.env.MCP_REPRO_STATE ?? join(tmpdir(), "mcp-rating-repro.json");
8const resource = "ui://rating-repro/card.html";
9const html = `<!doctype html>
10<html><head><meta charset="utf-8"><title>Rating reproduction</title>
11<style>:root{color-scheme:light dark}body{font:14px system-ui;color:CanvasText;background:Canvas;margin:12px}button{margin-right:8px}</style></head>
12<body>
13 <p>Saved rating: <strong id="rating">waiting for tool result</strong></p>
14 <button data-rating="up">Thumbs up</button>
15 <button data-rating="down">Thumbs down</button>
16 <p id="status" role="status"></p>
17 <script>
18 let sequence = 0;
19 const pending = new Map();
20 const status = document.getElementById("status");
21 const post = (message) => parent.postMessage({ jsonrpc: "2.0", ...message }, "*");
22 function request(method, params) {
23 return new Promise((resolve, reject) => {
24 const id = ++sequence;
25 pending.set(id, { resolve, reject });
26 post({ id, method, params });
27 });
28 }
29 function show(result) {
30 if (result?.structuredContent?.rating) {
31 document.getElementById("rating").textContent = result.structuredContent.rating;
32 }
33 }
34 addEventListener("message", ({ source, data }) => {
35 if (source !== parent || data?.jsonrpc !== "2.0") return;
36 if (data.method === "ui/resource-teardown" && data.id !== undefined) {
37 post({ id: data.id, result: {} });
38 return;
39 }
40 if (data.method === "ui/notifications/tool-result") show(data.params);
41 const waiter = pending.get(data.id);
42 if (!waiter || data.method) return;
43 pending.delete(data.id);
44 if (data.error) waiter.reject(new Error(data.error.message));
45 else waiter.resolve(data.result);
46 });
47 for (const button of document.querySelectorAll("button")) {
48 button.onclick = async () => {
49 document.querySelectorAll("button").forEach((b) => b.disabled = true);
50 status.textContent = "Saving...";
51 try {
52 const result = await request("tools/call", {
53 name: "set_rating", arguments: { rating: button.dataset.rating }
54 });
55 if (result.isError) throw new Error(result.content?.[0]?.text ?? "Tool failed");
56 show(result);
57 status.textContent = "Saved";
58 } catch (error) {
59 status.textContent = "Not saved: " + error.message;
60 } finally {
61 document.querySelectorAll("button").forEach((b) => b.disabled = false);
62 }
63 };
64 }
65 request("ui/initialize", {
66 protocolVersion: "2026-01-26",
67 appInfo: { name: "rating-repro", version: "1.0.0" },
68 appCapabilities: {}
69 }).then(() => {
70 post({ method: "ui/notifications/initialized", params: {} });
71 post({ method: "ui/notifications/size-changed", params: { width: 360, height: 150 } });
72 }).catch((error) => status.textContent = "Initialization failed: " + error.message);
73 </script>
74</body></html>`;
75
76async function readRating() {
77 try {
78 return JSON.parse(await readFile(stateFile, "utf8")).rating;
79 } catch (error) {
80 if (error.code === "ENOENT") return "unrated";
81 throw error;
82 }
83}
84
85async function dispatch(method, params = {}) {
86 if (method === "initialize") return {
87 protocolVersion: params.protocolVersion,
88 serverInfo: { name: "rating-repro", version: "1.0.0" },
89 capabilities: { tools: {}, resources: {} },
90 };
91 if (method === "ping") return {};
92 if (method === "tools/list") return { tools: [
93 {
94 name: "show_rating", description: "Display a rating card.",
95 inputSchema: { type: "object", properties: {}, additionalProperties: false },
96 annotations: { readOnlyHint: true },
97 _meta: { ui: { resourceUri: resource } },
98 },
99 {
100 name: "set_rating", description: "Persist the rating chosen in the card.",
101 inputSchema: {
102 type: "object", properties: { rating: { type: "string", enum: ["up", "down"] } },
103 required: ["rating"], additionalProperties: false,
104 },
105 annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
106 _meta: { ui: { visibility: ["app"] } },
107 },
108 ] };
109 if (method === "resources/list") return {
110 resources: [{ uri: resource, name: "Rating card", mimeType: "text/html;profile=mcp-app" }],
111 };
112 if (method === "resources/templates/list") return { resourceTemplates: [] };
113 if (method === "resources/read" && params.uri === resource) return {
114 contents: [{ uri: resource, mimeType: "text/html;profile=mcp-app", text: html }],
115 };
116 if (method === "tools/call") {
117 if (!["show_rating", "set_rating"].includes(params.name)) throw new Error("Unknown tool");
118 if (params.name === "set_rating") {
119 const rating = params.arguments?.rating;
120 if (!["up", "down"].includes(rating)) throw new Error("Expected rating up or down");
121 await writeFile(stateFile, JSON.stringify({ rating }) + "\n");
122 }
123 const rating = await readRating();
124 return { content: [{ type: "text", text: "Saved rating: " + rating }], structuredContent: { rating } };
125 }
126 throw new Error("Unsupported method: " + method);
127}
128
129// MCP stdio uses one JSON-RPC message per line. Keep stdout protocol-only.
130for await (const line of createInterface({ input: process.stdin })) {
131 if (!line.trim()) continue;
132 const request = JSON.parse(line);
133 if (request.id === undefined) continue;
134 try {
135 const result = await dispatch(request.method, request.params);
136 process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n");
137 } catch (error) {
138 process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message: error.message } }) + "\n");
139 }
140}
Isolated model-free verifier (verify.mjs)
javascript
1// Model-free Codex app-server lifecycle check, using an isolated temporary home.
2import assert from "node:assert/strict";
3import { spawn } from "node:child_process";
4import { once } from "node:events";
5import { createInterface } from "node:readline";
6import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
7import { tmpdir } from "node:os";
8import { join, dirname } from "node:path";
9import { fileURLToPath } from "node:url";
10
11const server = join(dirname(fileURLToPath(import.meta.url)), "server.mjs");
12const root = await mkdtemp(join(tmpdir(), "mcp-rating-proof-"));
13const home = join(root, "codex");
14const stateFile = join(root, "rating.json");
15let rpc;
16
17class Rpc {
18 constructor() {
19 this.nextId = 0;
20 this.pending = new Map();
21 this.process = spawn(process.env.CODEX_BIN ?? "codex", ["app-server"], {
22 env: { ...process.env, CODEX_HOME: home }, stdio: ["pipe", "pipe", "ignore"],
23 });
24 this.exited = once(this.process, "exit");
25 this.process.on("error", (error) => this.failPending(error));
26 this.process.on("exit", () => this.failPending(new Error("Codex app-server exited")));
27 createInterface({ input: this.process.stdout }).on("line", (line) => {
28 const message = JSON.parse(line);
29 const pending = this.pending.get(message.id);
30 if (!pending) return;
31 this.pending.delete(message.id);
32 clearTimeout(pending.timer);
33 pending.resolve(message);
34 });
35 }
36 failPending(error) {
37 for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(error); }
38 this.pending.clear();
39 }
40 request(method, params) {
41 const id = ++this.nextId;
42 return new Promise((resolve, reject) => {
43 const timer = setTimeout(() => {
44 this.pending.delete(id);
45 reject(new Error("Timeout waiting for " + method));
46 }, 30000);
47 this.pending.set(id, { resolve, reject, timer });
48 this.process.stdin.write(JSON.stringify({ id, method, params }) + "\n");
49 });
50 }
51 async initialize() {
52 const response = await this.request("initialize", {
53 clientInfo: { name: "rating-proof", version: "1.0.0" },
54 capabilities: { experimentalApi: true, extensions: {
55 "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
56 } },
57 });
58 assert.ok(response.result, JSON.stringify(response.error));
59 this.process.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
60 }
61 async close() {
62 if (this.process.exitCode === null && this.process.signalCode === null) this.process.kill("SIGTERM");
63 await this.exited;
64 }
65 async call(threadId, tool, args = {}) {
66 const response = await this.request("mcpServer/tool/call", {
67 threadId, server: "rating-repro", tool, arguments: args,
68 });
69 assert.ok(response.result, JSON.stringify(response.error));
70 assert.ok(!response.result.isError, JSON.stringify(response.result));
71 return response.result;
72 }
73}
74
75async function start() {
76 rpc = new Rpc();
77 await rpc.initialize();
78}
79
80try {
81 await mkdir(home);
82 await writeFile(join(home, "config.toml"), [
83 "[mcp_servers.rating-repro]",
84 "command = " + JSON.stringify(process.execPath),
85 "args = [" + JSON.stringify(server) + "]",
86 "[mcp_servers.rating-repro.env]",
87 "MCP_REPRO_STATE = " + JSON.stringify(stateFile), "",
88 ].join("\n"));
89
90 await start();
91 const started = await rpc.request("thread/start", { cwd: root, persistExtendedHistory: true });
92 assert.ok(started.result, JSON.stringify(started.error));
93 const threadId = started.result.thread.id;
94 // Persist synthetic history without asking a model to run.
95 const injected = await rpc.request("thread/inject_items", { threadId, items: [
96 { type: "message", role: "user", content: [{ type: "input_text", text: "Local MCP rating reproduction." }] },
97 ] });
98 assert.ok(injected.result, JSON.stringify(injected.error));
99 const shown = await rpc.call(threadId, "show_rating");
100 assert.equal(shown.structuredContent.rating, "unrated");
101 const resource = await rpc.request("mcpServer/resource/read", {
102 threadId, server: "rating-repro", uri: "ui://rating-repro/card.html",
103 });
104 assert.ok(resource.result?.contents[0].text.includes("ui/initialize"));
105 await rpc.call(threadId, "set_rating", { rating: "up" });
106 console.log("PASS: fresh conversation can load the app resource and save a rating.");
107 await rpc.close();
108
109 await start();
110 for (const [method, params] of [
111 ["mcpServerStatus/list", { threadId, serverName: "rating-repro", detail: "toolsAndAuthOnly" }],
112 ["mcpServer/tool/call", { threadId, server: "rating-repro", tool: "set_rating", arguments: { rating: "down" } }],
113 ]) {
114 const failed = await rpc.request(method, params);
115 assert.equal(failed.error?.code, -32600);
116 assert.equal(failed.error?.message, "thread not found: " + threadId);
117 console.log("PASS: " + method + " after restart returns -32600 thread not found.");
118 }
119 assert.equal(JSON.parse(await readFile(stateFile, "utf8")).rating, "up");
120 console.log("PASS: failed rating request did not modify the saved value.");
121
122 const resumed = await rpc.request("thread/resume", { threadId, excludeTurns: true });
123 assert.ok(resumed.result, JSON.stringify(resumed.error));
124 const tools = await rpc.request("mcpServerStatus/list", {
125 threadId, serverName: "rating-repro", detail: "toolsAndAuthOnly",
126 });
127 assert.ok(tools.result?.data.some((s) => s.name === "rating-repro" && Object.values(s.tools).some((t) => t.name === "set_rating")));
128 await rpc.call(threadId, "set_rating", { rating: "down" });
129 assert.equal(JSON.parse(await readFile(stateFile, "utf8")).rating, "down");
130 console.log("PASS: resuming the conversation restores tool discovery and rating saves.");
131 await rpc.close();
132
133 await start();
134 assert.ok((await rpc.request("thread/resume", { threadId, excludeTurns: true })).result);
135 assert.equal((await rpc.call(threadId, "show_rating")).structuredContent.rating, "down");
136 console.log("PASS: saved rating survives another Codex/MCP process restart.");
137 console.log("No turn/start or model-generation request was sent. T3's UI was not exercised.");
138} finally {
139 if (rpc) await rpc.close();
140 await rm(root, { recursive: true, force: true });
141}
Captured verifier output (verification.txt)
text
1PASS: fresh conversation can load the app resource and save a rating.
2PASS: mcpServerStatus/list after restart returns -32600 thread not found.
3PASS: mcpServer/tool/call after restart returns -32600 thread not found.
4PASS: failed rating request did not modify the saved value.
5PASS: resuming the conversation restores tool discovery and rating saves.
6PASS: saved rating survives another Codex/MCP process restart.
7No turn/start or model-generation request was sent. T3's UI was not exercised.

Workaround

In the isolated reproduction, thread/resume with excludeTurns: true restores access without starting a model turn. No fix was applied to the installed T3 app.