[bug] Apps SDK fullscreen opens blank on Android when widget/app is invoked in the first message of a new conversation

Hi everyone,

I’m trying to isolate what looks like a ChatGPT Android app issue with Apps SDK / MCP widget fullscreen behavior.

The issue seems to happen specifically when the widget/app is invoked in the first message of a new conversation.

I created a minimal MCP widget to reproduce it. The widget only renders one inline button. When the user taps the button, it calls:

await window.openai?.requestDisplayMode?.({ mode: "fullscreen" });

Then it replaces the body with a simple hello world screen.

The behavior on the ChatGPT Android app is:

Scenario Result
New conversation → first message asks to open the widget → tap fullscreen button Fullscreen opens blank
New conversation → send any normal message first, such as hi → then ask to open the same widget → tap fullscreen button Fullscreen works

So this does not seem to be a general fullscreen issue, because fullscreen works on Android after the conversation already has at least one previous message.

I also noticed the same pattern in another Apps SDK app I’m testing on Android:

Scenario Result
New conversation → first message asks ChatGPT to generate an image and then open the app/widget Fullscreen fails / opens blank
New conversation → send hi first → then ask ChatGPT to generate the image and open the app/widget Fullscreen works

This makes me suspect a cold-start / first-turn initialization issue in the ChatGPT Android host. The inline widget/app can render, but the fullscreen surface seems not to render correctly when the app/widget is first invoked on the first turn of a brand-new conversation.

I will attach an Android screen recording showing both cases:

  1. Failing case: new conversation, first message invokes the widget/app, fullscreen opens blank.
  2. Working case: new conversation, send hi first, then invoke the same widget/app, fullscreen works.

Here is the minimal repro code I used for the simple hello world widget:

import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const PORT = Number(process.env.PORT ?? 3000);

function toOrigin(value) {
  if (!value) return undefined;

  try {
    return new URL(value).origin;
  } catch {
    return undefined;
  }
}

const ORIGIN = toOrigin(process.env.BASE);
const TEMPLATE_URI = "ui://widget/widget.html";

const widgetHtml = `
<!doctype html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
    <meta http-equiv="Pragma" content="no-cache">
    <meta http-equiv="Expires" content="0">
  </head>

  <body>
    <button id="open">Abrir fullscreen</button>

    <script>
      document.getElementById("open").onclick = async () => {
        try {
          await window.openai?.requestDisplayMode?.({ mode: "fullscreen" });
        } catch (_) {}

        document.body.innerHTML =
          '<main style="height:100vh;display:grid;place-items:center;font:32px system-ui;">hello world</main>';
      };
    </script>
  </body>
</html>
`.trim();

function createServer() {
  const server = new McpServer({
    name: "hello-world-widget",
    version: "1.0.0",
  });

  registerAppResource(
    server,
    "hello-world-widget",
    TEMPLATE_URI,
    {},
    async () => ({
      contents: [
        {
          uri: TEMPLATE_URI,
          mimeType: RESOURCE_MIME_TYPE,
          text: widgetHtml,
          _meta: {
            ui: {
              ...(ORIGIN ? { domain: ORIGIN } : {}),
              csp: {
                connectDomains: [],
                resourceDomains: [],
              },
            },
          },
        },
      ],
    }),
  );

  registerAppTool(
    server,
    "hello_world_widget",
    {
      title: "Hello World Widget",
      description: "Abre um widget simples com um botão que mostra hello world em fullscreen.",
      inputSchema: {},
      outputSchema: {},
      _meta: {
        ui: {
          resourceUri: TEMPLATE_URI,
        },
        "openai/outputTemplate": TEMPLATE_URI,
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: false,
      },
    },
    async () => ({
      structuredContent: {},
      content: [
        {
          type: "text",
          text: "Widget aberto.",
        },
      ],
    }),
  );

  return server;
}

const app = createMcpExpressApp({ host: "0.0.0.0" });

app.all("/mcp", async (req, res) => {
  const server = createServer();

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  res.on("close", () => {
    transport.close().catch(() => {});
    server.close().catch(() => {});
  });

  try {
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  } catch (error) {
    console.error("MCP error:", error);

    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: "2.0",
        error: {
          code: -32603,
          message: "Internal server error",
        },
        id: null,
      });
    }
  }
});

app.listen(PORT, () => {
  console.log(`MCP server running - PORT: ${PORT} - ORIGIN: ${ORIGIN}`);
});

To run this code:

BASE=https://<ngrok-hash>.ngrok-free.app node index.mjs

Things I already tried:

  • Adding _meta.ui.domain with an HTTPS origin.
  • Testing different CSP configurations.
  • Adding legacy metadata aliases such as openai/widgetDomain and openai/widgetCSP.
  • Cache-busting the template URI, for example changing ui://widget/widget.html to versioned names.
  • Returning the same HTML from a real HTTP route and calling window.openai.setOpenInAppUrl(...).
  • Persisting state with window.openai.setWidgetState(...) before requesting fullscreen.
  • Rendering the fullscreen content before and after requestDisplayMode.
  • Testing requestModal() only as a diagnostic step. Modal is not a valid solution for my use case because I specifically need fullscreen.

The important finding is that the same widget/app can work on Android if the conversation already has a previous message. The failure appears when the app/widget is invoked on the first turn of a new conversation.

My questions:

  • Is fullscreen expected to work on Android when an Apps SDK widget/app is invoked in the first message of a brand-new conversation?
  • Is there any additional initialization step required before calling requestDisplayMode({ mode: "fullscreen" }) on Android?
  • Could this be a known Android host cold-start / first-turn issue?
  • Is there any difference in fullscreen behavior between widgets invoked after a normal assistant turn and widgets invoked immediately in the first assistant response?

Environment:

  • ChatGPT Android app version: 1.2026.181 (1)
  • Android device: Galaxy S23 Ultra - SM-S918B
  • Android OS version: 16
  • MCP package versions:
    • @modelcontextprotocol/sdk: ^1.29.0
    • @modelcontextprotocol/ext-apps: ^1.7.4
  • Node.js version: v22.23.1
  • Hosting/tunnel provider: ngrok
  • App mode:
    • Developer mode connector: yes
    • Published app: no

Any guidance would be appreciated. I’m trying to keep this repro intentionally minimal so the issue is easier to isolate.

I found one more important signal that may help narrow this down.

The fullscreen blank state seems to be correlated with something that happens before the widget actually opens in fullscreen.

On Android, when the assistant message finishes loading normally and ChatGPT renders the message action buttons below the response — for example copy, share, and more options — then tapping the widget button and calling:

await window.openai?.requestDisplayMode?.({ mode: "fullscreen" });

works correctly.

But when those ChatGPT message action buttons do not appear below the assistant message, the fullscreen request still appears to open the fullscreen surface, but the fullscreen content renders blank.

I will attach two screenshots:

  1. Working case — the assistant message has the normal ChatGPT action buttons below it, then fullscreen works.
  2. Failing case — those action buttons are missing, then fullscreen opens blank.

So this may not be a bug inside the widget’s fullscreen rendering itself. It looks more like the ChatGPT Android host may not have fully completed the first assistant turn / message UI hydration / app host initialization before the fullscreen transition happens.

In other words, the missing message action buttons seem to be an external symptom that the host is not fully ready yet. When the message UI is fully initialized, fullscreen works. When it is not, fullscreen opens but the widget surface stays blank.

This also matches the original repro pattern: the issue happens mostly when the widget/app is invoked in the first assistant response of a brand-new conversation. If I send a normal message first, such as hi, and only then invoke the widget, the message UI seems to initialize normally and fullscreen works.

This makes me suspect the root cause may be somewhere in the Android ChatGPT host lifecycle before or during the first app/widget attachment, rather than in requestDisplayMode() alone.

Anyone from OpenAI who can help?

@OpenAI_Support can you help about this?