Manage MCPs panel hangs on "Loading MCP servers..." — silent .catch in workbench bundle swallows polling errors

, ,

Manage MCPs panel hangs on “Loading MCP servers…” — silent .catch() in workbench bundle swallows polling errors

Category: Bug — Antigravity IDE (UI / MCP)
Affects: Antigravity 1.107.0 (commit 62335c71d47037adf0a8de54e250bb8ea6016b15, 2026-04-02), Windows 11
Severity: High — makes the Manage MCPs surface permanently unusable; the failure is invisible (no user-facing error, no log line)
Reproducible: Yes, 100% once triggered; persists across relaunches because the broken editor pane is auto-restored


Symptom

Clicking Manage MCPs (top-right title-bar button) opens a webview tab titled “Manage MCP servers” showing:

Loading MCP servers… ⟳ Refreshing…

The spinner never completes. The list never populates. There is no error banner, no toast, and nothing written to Antigravity.log, exthost.log, or renderer.log beyond the normal startup traces. The chat panel itself works fine and the configured MCP servers function normally — only the management UI is affected.

Because Antigravity’s editor-pane serializer persists this panel to state.vscdb whenever it’s open at shutdown, it is auto-restored on every launch after the first hang — so the spinner appears immediately on every subsequent startup with no user action required.

Reproduction

  1. Have at least one enabled MCP server in ~/.gemini/antigravity/mcp_config.json (verified to reproduce with 10 enabled, and also with zero enabled — see “Isolation” below).
  2. Click the Manage MCPs button in the title bar.
  3. Observe the “Loading MCP servers…” spinner.
  4. Wait indefinitely.
  5. Close Antigravity. Relaunch. The broken panel is auto-restored immediately.

Isolation — what this is NOT

We bisected the config down to zero enabled servers (set "disabled": true on all 24 entries) and the panel still hangs identically. So:

  • :cross_mark: Not caused by any individual MCP server hanging handshake
  • :cross_mark: Not caused by remote-proxied servers (context7, developer-knowledge via mcp-remote)
  • :cross_mark: Not caused by the local Qdrant backend being down
  • :cross_mark: Not caused by OAuth state corruption (verified: loadCodeAssist + fetchAvailableModels succeed in every session log)
  • :cross_mark: Not caused by the uss-artifactReview uncaught exceptions in main.log (those are present in working environments too; confirmed noise)
  • :cross_mark: Not caused by extensions (reproduces with a clean profile and no MCP-related extensions)

Root cause

Reading the workbench bundle (resources/app/out/vs/workbench/workbench.desktop.main.js), the MCP state service — minified class FYn — drives the panel via this polling loop:

// class FYn constructor:
constructor(...) {
  this.a = [];     // server states
  this.b = false;  // mcpServersInitialized flag — the gate
  this.c = true;   // isLoading flag
  this.refreshMcpServers();   // fire-and-forget on construction
  this.D(this.w.onDidChangeLsClient(h => h && !this.b && this.refreshMcpServers()));
}

async refreshMcpServers(shallow = false, serverName) {
  if (this.y.getOAuthTokenInfo() === null) return;   // (A) silent early exit
  await this.w.isInitialized();
  const s = this.w.lsClient;
  if (!s) return;                                     // (B) silent early exit
  this.c = true;
  s.refreshMcpServers({ shallow, serverName });
  return new Promise(resolve => {
    const r = setInterval(() => {
      s.getMcpServerStates({}).then(a => {
        if (a.isLoading) return;                      // keep polling
        clearInterval(r);
        this.c = false;
        this.updateMcpServers(a.states);              // ONLY path that sets this.b = true
        resolve();
      }).catch(() => {});                             // (C) swallows ALL errors — no log, no retry backoff, poll continues forever
    }, 300);
  });
}

The render function (class Oui, method vb) has this gate:

vb(e) {
  if (!this.mb.mcpServersInitialized) return;   // (D) gate never opens if above paths fail
  this.kb.style.display = "none";               // would hide "Loading MCP servers..." — never reached
  // ...render list
}

Four failure modes, all producing the identical symptom

  1. Early exit AgetOAuthTokenInfo() returns null at construction. The fallback onDidChangeLsClient listener only retries if !this.b, but this.b never becomes true in any failure path, so the listener fires uselessly (or not at all, if the LS client was already steady-state before the service constructed).

  2. Early exit BlsClient is null when the service initialises. Same fallback issue.

  3. Poll returns isLoading: true forever — gRPC round-trips succeed but the LS side never flips its own loading flag. The promise never resolves.

  4. Poll rejects silently.catch(() => {}) on the getMcpServerStates() call swallows everything. No error surfaces, the interval continues at 300 ms forever firing rejected requests that go nowhere. No log line is ever produced.

The rendering code has no branch for “couldn’t initialize” — the loading label is owned exclusively by the success path. So the user sees the initial spinner and literally nothing else ever happens.

Why this persists across restarts

Antigravity’s editor-pane serializer for this input is:

var NLd = class {
  canSerialize(t) { return true; }
  serialize(t)    { return ""; }
  deserialize(t,e){ return t.createInstance(Pui); }
};
// ...
getEditorFactory().registerEditorSerializer(Pui.ID, NLd);

Pui.ID === "workbench.input.antigravityConfigurePluginsPageInput". Because canSerialize always returns true and the editor is persisted to each workspace’s state.vscdb → memento/workbench.parts.editor, any workspace that had the panel open during a failed attempt will silently re-open it on every subsequent launch. Users see “spinning immediately on every startup, forever” with no obvious cause.

Suggested fixes (any one of these is sufficient)

  1. Surface the error in .catch. At minimum console.error it, ideally propagate through a UI error banner. .catch(() => {}) of a polling RPC is an anti-pattern.
  2. Let the render path handle “failed to initialise” as a terminal state. Set this.b = true (with this.a = []) after N consecutive poll failures so vb() can render the “No servers found.” empty-list branch the bundle already has, instead of the gate-closed spinner.
  3. Retry with exponential backoff instead of a 300 ms fixed interval that silently burns CPU forever.
  4. Make onDidChangeLsClient idempotent. If the LS client was already present when FYn constructed (common race), the fallback never fires. Either (a) check lsClient non-null at construction and re-enter refreshMcpServers, or (b) fire onDidChangeLsClient retroactively on subscribe.
  5. Don’t auto-restore a known-failed editor pane. The serializer’s canSerialize could return false when mcpServersInitialized === false at shutdown time, preventing the panel from auto-reopening in a broken state.

Workaround we applied (for the record)

With Antigravity closed, we surgically removed the persisted editor entry from every workspace’s state:

# For each C:\Users\<user>\AppData\Roaming\Antigravity\User\workspaceStorage\*\state.vscdb (and .backup):
#   Load key "memento/workbench.parts.editor" as JSON
#   Walk the grid, remove any editor with id == "workbench.input.antigravityConfigurePluginsPageInput"
#   Fix up the "mru" array indices
#   Write back

After this, the broken panel stops auto-reopening on startup. The chat panel continues to function normally; users can manage servers by editing mcp_config.json directly with any text editor. The underlying FYn bug is still present — clicking the Manage MCPs title-bar button still hangs — but at least the IDE is usable again.

Evidence files

  • Antigravity.log during a hang session: only shows Language server started, loadCodeAssist, fetchAvailableModels. No MCP-related entries at all while the panel is spinning — confirming the silent-catch is swallowing RPC errors with no logging.
  • renderer.log: no errors associated with the MCP panel timeframe.
  • workspaceStorage/<hash>/state.vscdb → memento/workbench.parts.editor contains the persisted antigravityConfigurePluginsPageInput entry across 6 distinct workspaces on our machine.