Skip to content

feat(playground): persist MCP App UI metadata across restarts#2057

Merged
samuv merged 5 commits intomainfrom
playground-persist-mcp-app
Apr 21, 2026
Merged

feat(playground): persist MCP App UI metadata across restarts#2057
samuv merged 5 commits intomainfrom
playground-persist-mcp-app

Conversation

@samuv
Copy link
Copy Markdown
Collaborator

@samuv samuv commented Apr 21, 2026

The Playground's MCP App iframe map (tool name → { serverName, resourceUri }) lived only in a module-level variable in main/src/chat/mcp-tools.ts (cachedUiMetadata) that was cleared and rebuilt on every createMcpTools() call. That meant on a fresh app launch the cache was empty until the user kicked off a new chat stream — so any historical message in main/src/chat/threads-storage.ts whose tool parts had a _meta.ui resource silently rendered as a plain tool output instead of its McpAppView iframe, because chat-message.tsx gates McpAppView on the tool name being present in the metadata map consumed by useMcpAppMetadata.

This PR mirrors the cache into SQLite (same DB that already backs chat threads, settings, and feature flags) and seeds the in-memory map lazily from the DB on the first getCachedUiMetadata() call, so historical MCP App iframes show up immediately after a restart. The renderer and IPC surface are unchanged.

Kapture.2026-04-21.at.11.48.26.mp4

DB layer (main/src/db/)

  • New migration 004-mcp-app-ui-metadata.tsmcp_app_ui_metadata(tool_name TEXT PRIMARY KEY, server_name TEXT NOT NULL, resource_uri TEXT NOT NULL, updated_at INTEGER NOT NULL) plus idx_mcp_app_ui_metadata_server for possible future per-server cleanup. Registered as id 4 in migrator.ts and applied in the in-memory test DB helper.
  • New writers/mcp-app-ui-metadata-writer.tsreplaceAllMcpAppUiMetadata(map) runs DELETE + bulk INSERT inside a single db.transaction(), so the row set is atomically swapped on each stream and stale entries are pruned in the same operation. clearAllMcpAppUiMetadata() is exposed for symmetry with other writers. No encryption: tool_name and resource_uri are not secrets (unlike the ai_providers table).
  • New readers/mcp-app-ui-metadata-reader.tsreadAllMcpAppUiMetadata() returns a Record<string, { serverName; resourceUri }> shaped exactly like the existing in-memory cachedUiMetadata, so the cache can be seeded without an adapter.

Cache plumbing (main/src/chat/mcp-tools.ts)

  • Add a uiMetadataLoaded latch and an ensureUiMetadataLoaded() helper that calls readAllMcpAppUiMetadata() exactly once and populates the in-memory map. Failures are logged and swallowed — the cache is advisory, and the next chat stream will rebuild it anyway.
  • getCachedUiMetadata() calls ensureUiMetadataLoaded() before returning, so the first renderer IPC (chat:get-tool-ui-metadata) after startup returns the persisted map.
  • createMcpTools() keeps its existing reset-and-rebuild semantics, marks the cache as "loaded" when it resets (so a concurrent read doesn't trigger a redundant DB fetch), and calls replaceAllMcpAppUiMetadata(cachedUiMetadata) after the Sentry breadcrumb — including when the map is empty, so uninstalling an MCP workload with UI tools cleans the table on the next stream.

How to validate

  1. Open a Playground chat that invokes a UI-enabled MCP tool and confirm the iframe renders.
  2. Fully quit and relaunch the app. Navigate to the same thread without sending a new message. The historical tool part now mounts McpAppView immediately (the metadata map is hydrated from SQLite on the first getToolUiMetadata IPC).
  3. Uninstall the MCP workload that provided the tool, start a new chat stream. The removed tool's row is pruned from mcp_app_ui_metadata by the transactional replace in createMcpTools().

Tests

  • 5 new unit tests in main/src/db/__tests__/writers-readers.test.ts covering empty-table read, round-trip, replace-without-accumulation (second call prunes entries not in the new map), empty-map replace clearing the table, and clearAllMcpAppUiMetadata.
  • pnpm run type-check and the full main/src/db + main/src/chat Vitest suites are green (71 + 56 tests).

Not changed in this PR (deliberate)

  • The renderer flow is unchanged. useMcpAppMetadata still calls chat:get-tool-ui-metadata and listens to chat:stream:tool-ui-metadata; seeding from SQLite happens transparently inside getCachedUiMetadata().
  • No electron-store migration: this cache was never persisted before, so there is nothing to reconcile.
  • Stale rows across uninstalls are pruned lazily on the next createMcpTools() call rather than reactively on workload delete. If a user reopens a thread that referenced an uninstalled server before any new stream runs, the iframe will render and fail through the existing error path in fetchUiResource + McpAppView — same behavior as today when a workload is merely stopped.
  • _meta.ui.csp / permissions / prefersBorder from resources/read are not persisted. They are re-fetched per resource load inside McpAppView and aren't part of the tool→server map.

@samuv samuv self-assigned this Apr 21, 2026
Copilot AI review requested due to automatic review settings April 21, 2026 09:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Persists the MCP App “tool name → { serverName, resourceUri }” UI metadata map in SQLite and lazily hydrates the in-memory cache on first access, so historical MCP App iframes render immediately after an app restart (without requiring a new chat stream).

Changes:

  • Added SQLite migration for mcp_app_ui_metadata plus reader/writer APIs to replace/clear/read the map.
  • Updated main/src/chat/mcp-tools.ts to lazily load UI metadata from SQLite and persist the rebuilt cache after tool discovery.
  • Added DB unit tests covering read/replace/clear behaviors, and updated the in-memory test DB helper to apply the new migration.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
main/src/db/writers/mcp-app-ui-metadata-writer.ts Adds transactional “replace all” + clear writer for the UI metadata table.
main/src/db/readers/mcp-app-ui-metadata-reader.ts Adds reader returning the same shape as the in-memory cache.
main/src/db/migrator.ts Registers migration 004.
main/src/db/migrations/004-mcp-app-ui-metadata.ts Creates mcp_app_ui_metadata table and server-name index.
main/src/db/tests/writers-readers.test.ts Adds unit tests for the new reader/writer.
main/src/db/tests/test-helpers.ts Applies migration 004 in the in-memory test DB.
main/src/chat/mcp-tools.ts Adds lazy DB hydration + persistence of the cache after createMcpTools().

Comment thread main/src/chat/mcp-tools.ts Outdated
Comment thread main/src/chat/mcp-tools.ts
samuv added 3 commits April 21, 2026 12:09
Introduces migration 004 with mcp_app_ui_metadata(tool_name PK, server_name, resource_uri, updated_at) and an index on server_name. Applies it in the in-memory test DB helper. Groundwork for persisting the Playground MCP App UI metadata cache across app restarts.
Adds readAllMcpAppUiMetadata, replaceAllMcpAppUiMetadata (transactional DELETE + bulk INSERT so each call is atomic and prunes stale rows), and clearAllMcpAppUiMetadata. Covered by 5 new tests: empty read, round-trip, replace-without-accumulation, empty-replace clears the table, and clearAll.
getCachedUiMetadata() now seeds the in-memory cache from SQLite on first access via ensureUiMetadataLoaded(), and createMcpTools() calls replaceAllMcpAppUiMetadata(cachedUiMetadata) at the end of each stream setup (even when empty, so servers that lost all UI tools have their stale rows pruned). Historical tool parts with _meta.ui now remount McpAppView on app restart without first requiring a new streaming session to repopulate the cache.
@samuv samuv force-pushed the playground-persist-mcp-app branch from 231f777 to 2cd61b8 Compare April 21, 2026 10:09
samuv added 2 commits April 21, 2026 12:16
…failure

When createMcpTools() used to reset the module-level cachedUiMetadata at
the top and unconditionally persist it at the bottom, a transient
fetchWorkloads() or getEnabledMcpTools() rejection would wipe the
previously persisted rows — defeating the whole point of the cache.
Build discovery into a local map and only swap + persist once the
required calls have resolved.

Made-with: Cursor
…DB read failure

ensureUiMetadataLoaded() was latching uiMetadataLoaded=true before the
SQLite read, so a transient failure (e.g. database locked at startup)
would permanently disable retries for the rest of the process — leaving
the cache empty until a new chat stream rebuilt it. Move the latch
assignment after the successful read so later callers retry the load.

Made-with: Cursor
@github-actions github-actions Bot added size/M and removed size/S labels Apr 21, 2026
@samuv samuv enabled auto-merge (squash) April 21, 2026 10:28
@samuv samuv merged commit 591e851 into main Apr 21, 2026
16 checks passed
@samuv samuv deleted the playground-persist-mcp-app branch April 21, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants