External Publication
Visit Post

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

OpenAI Developer Community July 3, 2026
Source

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.

  • Video when fullscreen not works
  • Video when fullscreen works

Discussion in the ATmosphere

Loading comments...