MCP Apps: client stopped issuing resources/read; ui:// widgets don't mount (regression in 3.9.16)

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Since Cursor 3.9.16, MCP Apps UI widgets (SEP-1865 — HTML resources served at
ui:// URIs) no longer render inline. The client declares the
io.modelcontextprotocol/ui capability and lists the resource, but never issues
the resources/read call needed to fetch the widget HTML, so the tool result
falls back to raw text/JSON instead of mounting the widget.

I bisected this to an exact release: it works through 3.9.8 and is broken from
3.9.16 onward (details + protocol traces in Additional Information below).

Notes:

  • The client still DECLARES io.modelcontextprotocol/ui and lists the resource;
    it just never reads it.
  • Transport- and mechanism-independent (see Additional Information).
  • Distinct from the known “_meta stripped from ui/notifications/tool-result”
    issue, which presupposes the widget already mounted — this fails one step earlier.

Steps to Reproduce

  1. Run a minimal MCP server (@modelcontextprotocol/sdk + @modelcontextprotocol/
    ext-apps) that registers:
    • one ui:// HTML resource (registerAppResource), and
    • one tool that links that resource via tool-definition _meta.ui.resourceUri
      (registerAppTool).
  2. Connect it to Cursor and, in a chat, call the tool.
  3. Observe the MCP server’s inbound JSON-RPC log.

Result: the server receives initialize → tools/list → resources/list →
tools/call, but NO resources/read — so the widget never mounts and the result
renders as raw text.

Each version below was tested in an isolated profile (–user-data-dir /
–extensions-dir), confirmed by its About dialog, against the same server.

Expected Behavior

The client fetches the linked resource (sends resources/read for the ui:// URI)
and mounts the returned HTML inline as an MCP App widget — the behavior in
Cursor 3.9.8 and earlier.

Operating System

MacOS

Version Information

Version: 3.10.20
VS Code Extension API: 1.125.0
Commit: 23b9fb205fe595ea2be29da7214e19762d037fc0
Date: 2026-07-07T07:03:33.071Z
Layout: Agent Window
Build Type: Stable
Release Track: Default
Electron: 40.10.3
Chromium: 144.0.7559.236
Node.js: 24.15.0
V8: 14.4.258.32-electron.0
xterm.js: 6.1.0-beta.256
OS: Darwin arm64 24.6.0

For AI issues: which model did you use?

Opus 4.8

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey there! Thanks for the report. The JSON-RPC traces and per-version isolation testing made this quick to dig into.

Two things up front: this isn’t a code regression, and it’s not your server or config. We compared the widget render path across 3.9.8, 3.9.16, and 3.10.20 and it’s unchanged, so the bisect isn’t pointing at a code change you can work around. resources/read never fires because the inline MCP Apps widget rendering isn’t currently active for your session. That’s controlled on our end (not toggleable on yours), which is also why the result falls back to text. Same root cause as this thread.

Two quick things that’d help us pin it down, if you don’t mind:

  1. Does the widget fail to mount on every tool call, or does it sometimes render?
  2. Does a window reload (Cmd+Shift+P → “Developer: Reload Window”) or toggling the MCP server off/on in settings ever make it appear? (If it does, that’s also a stop-gap for you.)

I’m tracking this internally and will update here when there’s progress.

  1. It hasn’t popped up again since the upgrade.

  2. Reloading the window doesn’t seem to do anything to show it.

Seems like we’ll just have to wait until widely available

Thanks Mohit!

  1. Every call vs. sometimes: I can’t answer this well from the bisect (too few calls). In normal use on 3.10.20 I haven’t seen it render at all, which is what kicked off the investigation. Happy to run a batch of repeated calls on 3.10.20 if a clean “N calls, M mounts” number would help.

  2. Reload / toggle: reconnecting the MCP server didn’t make it appear in my runs, but I didn’t do a rigorous reload-then-retry loop, so I wouldn’t call that conclusive either. I can test that specifically if useful.

Appreciate the offer, we’ve since reproduced this on our end, so it’s confirmed on our side rather than your server or config.

To close out your two questions: in the affected state, it’s consistent, not intermittent (it fails on every tool call), and reconnecting the server or reloading the window doesn’t bring the widget back, which lines up with what you saw. So there’s no reliable workaround on your side for now.

It’s logged with our team, and I’ll update this thread as soon as there’s progress.

Hey @Annalise_Tarhan,

A fix for MCP App widgets not rendering inline should have landed in Cursor 3.12. Please give it another try once you’re on that version!

cc @kennycastaneda

Hi Colin!

Thank you for your response, but I’m still running into the same issue. Here’s what the robot had to say:

~~~

Still reproduces on 3.13.25 — but I think I’ve found why it looks fixed on your end.

The discriminator is how the MCP server is registered, not the version.

I built one minimal MCP Apps server (a single ui:// resource via registerAppResource, one tool linking it via _meta.ui.resourceUri) and registered the same file on disk twice — once globally, once project-scoped — in the same Cursor session:

Scope Identifier tools/call resources/read Widget
Global (~/.cursor/mcp.json) user-mcpAppsProbe 3 7 mounts
Project (<ws>/.cursor/mcp.json) project-0-<ws>-mcpAppsProbe2 3 0 never mounts

Global scope, first call — the read fires 150ms later:

17:58:15.132  jsonrpc_recv  tools/call
17:58:15.149  tool_call     probe_show_widget
17:58:15.297  jsonrpc_recv  resources/read
17:58:15.317  resource_read ui://probe/widget.html

Project scope, first call — cold cache, so a read is mandatory if the widget is mounting at all:

18:02:13.404  jsonrpc_recv  tools/call
18:02:13.444  tool_call     probe_show_widget
              (nothing further)

One caveat so the numbers aren’t misread: the global server doesn’t re-read on every call, because the resource HTML is cached per server once mounted. The meaningful signal is “reads at all, ever” vs. “never reads” — not a 1:1 call-to-read ratio.

My real server (project-scoped) is at 15 tool calls / 0 resources/read this session. It’s verified correct at the wire level — I drove it over raw stdio, bypassing Cursor entirely:

  • tools/list exposes both _meta.ui.resourceUri and _meta["ui/resourceUri"]
  • resources/list advertises ui://udacity/video-player.html as text/html;profile=mcp-app
  • a direct resources/read returns 851,606 bytes of HTML

So the server answers correctly the moment anyone asks. Cursor never asks.

Hypothesis. In the widget mount path, the resource URI is resolved by looking the tool up in the tools cache:

const m = xXS(i, Object.keys(p), c.allServers());   // normalize providerIdentifier
const v = (p[m] ?? []).find(_ => _.name === r || _.originalName === r);
const b = TXS(v);                                    // v.meta.ui.resourceUri

If that identifier normalization doesn’t map a project-0-<workspace>-<name> identifier onto its tools-cache key, v is undefined, b is undefined, the widget returns null, and no resources/read is ever issued — silently, with no error surfaced anywhere. That would explain a fix that tests green against a globally-scoped server while every project-scoped server stays dark.

Worth noting the failure is invisible from the user side: no error, no console warning, the tool result just quietly renders as text.

Happy to share the minimal repro server if it’s useful — it’s one file and needs only @modelcontextprotocol/sdk, ext-apps, and zod.

A repro is always helpful, @Annalise_Tarhan !

@Colin — minimal repro below. Install dependencies, register the same server.mjs once
globally and once in a workspace (with the two configurations below), then call
probe_show_widget on each server. Each instance appends its inbound JSON-RPC methods to
global.log or project.log.

package.json
{
  "name": "cursor-mcp-apps-repro",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/ext-apps": "^1.7.4",
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^3.23.8"
  }
}
server.mjs
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";

const LABEL = process.env.PROBE_LABEL ?? "unlabeled";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LOG = path.join(HERE, `${LABEL}.log`);
const RESOURCE_URI = "ui://probe/widget.html";

const record = (event, extra = {}) =>
  fs.appendFileSync(
    LOG,
    JSON.stringify({ t: new Date().toISOString(), label: LABEL, event, ...extra }) + "\n",
  );

record("server_start", { pid: process.pid });

const server = new McpServer(
  { name: `mcp-apps-probe-${LABEL}`, version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } },
);

registerAppTool(
  server,
  "probe_show_widget",
  {
    title: "Probe Show Widget",
    description:
      "Renders a tiny inline MCP App widget. Used to test whether the host issues resources/read.",
    annotations: { readOnlyHint: true, openWorldHint: false },
    inputSchema: {
      nonce: z.string().optional().describe("Arbitrary value so repeated calls can't be deduped."),
    },
    _meta: { ui: { resourceUri: RESOURCE_URI } },
  },
  async ({ nonce }) => {
    record("tool_call", { tool: "probe_show_widget", nonce: nonce ?? null });
    return {
      content: [
        {
          type: "text",
          text:
            `probe widget requested (scope=${LABEL}, nonce=${nonce ?? "none"}). ` +
            `If no green box rendered above, the host never issued resources/read.`,
        },
      ],
      structuredContent: { scope: LABEL, nonce: nonce ?? null },
    };
  },
);

registerAppResource(
  server,
  "Probe Widget",
  RESOURCE_URI,
  { description: "Tiny HTML widget for MCP Apps mount testing." },
  async () => {
    // Reaching this line means the host actually fetched the widget HTML.
    record("resource_read", { uri: RESOURCE_URI });
    return {
      contents: [
        {
          uri: RESOURCE_URI,
          mimeType: "text/html;profile=mcp-app",
          text:
            `<!doctype html><html><body style="margin:0;font:14px system-ui">` +
            `<div style="padding:10px;border:2px solid #2ea043;border-radius:8px;background:#0d1117;color:#2ea043">` +
            `MCP App widget MOUNTED — scope: <b>${LABEL}</b>` +
            `</div></body></html>`,
        },
      ],
    };
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);

// Wrap once, after connect(), so each inbound message is recorded a single time.
const downstream = transport.onmessage;
transport.onmessage = (msg, extra) => {
  if (msg && typeof msg === "object" && "method" in msg) {
    record("jsonrpc_recv", { method: msg.method });
  }
  downstream?.(msg, extra);
};

record("connected");
mcp.json registrations

Global — ~/.cursor/mcp.json:

{
  "mcpServers": {
    "mcpAppsProbe": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/server.mjs"],
      "env": { "PROBE_LABEL": "global" }
    }
  }
}

Project — <workspace>/.cursor/mcp.json:

{
  "mcpServers": {
    "mcpAppsProbe2": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/server.mjs"],
      "env": { "PROBE_LABEL": "project" }
    }
  }
}

Hey @Annalise_Tarhan! Thank you for the excellent repro.

Your global-vs-project split matches what we see: widgets from globally-configured servers render correctly, but a server configured in a project’s .cursor/mcp.json never gets its widget fetched, so the result falls back to text.

This is tracked as a known issue with project-scoped servers specifically, and I’ve added your repro to it.

In the meantime, registering the same server in your global ~/.cursor/mcp.json will get the widget rendering again!