diff --git a/package.json b/package.json
index d4d930b..6bfc294 100644
--- a/package.json
+++ b/package.json
@@ -152,10 +152,6 @@
{
"from": "src/main/engine/templates",
"to": "templates"
- },
- {
- "from": "src/main/engine/mcp-views",
- "to": "mcp-views"
}
],
"protocols": [
diff --git a/src/main/engine/mcp-views/review-metadata.html b/src/main/engine/mcp-view-builder.ts
similarity index 60%
rename from src/main/engine/mcp-views/review-metadata.html
rename to src/main/engine/mcp-view-builder.ts
index 701bd4d..5461109 100644
--- a/src/main/engine/mcp-views/review-metadata.html
+++ b/src/main/engine/mcp-view-builder.ts
@@ -1,7 +1,41 @@
-
-
-
Review Metadata
-
-
-
-
-
Waiting for metadata...
-
-
-
+`;
+}
diff --git a/src/main/engine/mcp-views.ts b/src/main/engine/mcp-views.ts
index 9200263..066a54e 100644
--- a/src/main/engine/mcp-views.ts
+++ b/src/main/engine/mcp-views.ts
@@ -1,5 +1,6 @@
/**
- * MCP App Review Views — loaded from HTML files in the `mcp-views/` directory.
+ * MCP App Review Views — generated at runtime from shared boilerplate
+ * + per-view configuration via `buildMcpView()`.
*
* Each function returns a self-contained HTML page that uses the
* `App` class from `@modelcontextprotocol/ext-apps` (loaded via
@@ -9,77 +10,120 @@
* in MCP hosts that support MCP Apps (Claude, ChatGPT, VS Code, etc.).
*/
-import { readFileSync } from 'fs';
-import path from 'path';
+import { buildMcpView } from './mcp-view-builder';
-/**
- * Resolve candidate directories for MCP view HTML files.
- * Checks `__dirname/mcp-views`, `dist/main/engine/mcp-views`,
- * `src/main/engine/mcp-views`, and `process.resourcesPath/mcp-views`.
- */
-export function resolveMcpViewsDirs(options?: {
- moduleDir?: string;
- cwd?: string;
- resourcesPath?: string;
-}): string[] {
- const moduleDir = options?.moduleDir ?? __dirname;
- const cwd = options?.cwd ?? process.cwd();
- const resourcesPath = options?.resourcesPath ?? process.resourcesPath;
+/* ── Review Post ────────────────────────────────────────────────────── */
- const dirs = [
- path.resolve(moduleDir, 'mcp-views'),
- path.resolve(cwd, 'dist', 'main', 'engine', 'mcp-views'),
- path.resolve(cwd, 'src', 'main', 'engine', 'mcp-views'),
- ];
-
- if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
- dirs.unshift(path.resolve(resourcesPath, 'mcp-views'));
- }
-
- return Array.from(new Set(dirs));
+export function reviewPostHtml(): string {
+ return buildMcpView({
+ title: 'Review Post',
+ waitingMessage: 'Waiting for post data...',
+ acceptLabel: 'Publish',
+ discardLabel: 'Discard Draft',
+ renderBody: `\
+ const post = data.post || {};
+ const wc = (post.content || "").split(/\\s+/).filter(Boolean).length;
+ document.getElementById("review").innerHTML = \`
+ \${esc(post.title || "Untitled")}
+
+ Draft
+ \${wc} words
+
+ \${post.categories?.length ? 'Categories: ' + post.categories.map(c => esc(c)).join(", ") + '
' : ''}
+ \${post.tags?.length ? 'Tags: ' + post.tags.map(t => esc(t)).join(", ") + '
' : ''}
+ \${post.excerpt ? 'Excerpt
' + esc(post.excerpt) + '
' : ''}
+ Content
+ \${esc(post.content || "")}
+
+
+
+
+ \`;`,
+ });
}
-/**
- * Load an MCP view HTML file by name, searching candidate directories.
- * Throws if the file cannot be found in any candidate directory.
- */
-export function loadViewHtml(
- filename: string,
- options?: Parameters[0],
-): string {
- const dirs = resolveMcpViewsDirs(options);
- for (const dir of dirs) {
- try {
- return readFileSync(path.join(dir, filename), 'utf-8');
- } catch {
- // try next directory
- }
- }
- throw new Error(
- `MCP view "${filename}" not found in any of: ${dirs.join(', ')}`,
- );
+/* ── Review Script ──────────────────────────────────────────────────── */
+
+export function reviewScriptHtml(): string {
+ return buildMcpView({
+ title: 'Review Script',
+ waitingMessage: 'Waiting for script data...',
+ acceptLabel: 'Create Script',
+ discardLabel: 'Discard',
+ renderBody: `\
+ const p = data.preview || data;
+ document.getElementById("review").innerHTML = \`
+ \${esc(p.title || "Untitled Script")}
+ \${esc(p.kind || "script")}
+ Python Code
+ \${esc(p.content || "(code not included in preview)")}
+
+
+
+
+ \`;`,
+ });
}
-export function reviewPostHtml(
- options?: Parameters[0],
-): string {
- return loadViewHtml('review-post.html', options);
+/* ── Review Template ────────────────────────────────────────────────── */
+
+export function reviewTemplateHtml(): string {
+ return buildMcpView({
+ title: 'Review Template',
+ waitingMessage: 'Waiting for template data...',
+ acceptLabel: 'Create Template',
+ discardLabel: 'Discard',
+ renderBody: `\
+ const p = data.preview || data;
+ document.getElementById("review").innerHTML = \`
+ \${esc(p.title || "Untitled Template")}
+ \${esc(p.kind || "template")}
+ Liquid Template
+ \${esc(p.content || "(template not included in preview)")}
+
+
+
+
+ \`;`,
+ });
}
-export function reviewScriptHtml(
- options?: Parameters[0],
-): string {
- return loadViewHtml('review-script.html', options);
-}
+/* ── Review Metadata ────────────────────────────────────────────────── */
-export function reviewTemplateHtml(
- options?: Parameters[0],
-): string {
- return loadViewHtml('review-template.html', options);
-}
-
-export function reviewMetadataHtml(
- options?: Parameters[0],
-): string {
- return loadViewHtml('review-metadata.html', options);
+export function reviewMetadataHtml(): string {
+ return buildMcpView({
+ title: 'Review Metadata',
+ waitingMessage: 'Waiting for metadata...',
+ acceptLabel: 'Apply Changes',
+ discardLabel: 'Discard',
+ extraCss: `\
+ .diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; }
+ .diff-table th, .diff-table td { padding: 6px 10px; border: 1px solid #dee2e6; text-align: left; font-size: 0.85rem; }
+ .diff-table th { background: #f1f3f5; font-weight: 600; }
+ .diff-old { background: #ffeef0; }
+ .diff-new { background: #e6ffed; }`,
+ extraJsHelpers: `function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }`,
+ renderBody: `\
+ const current = data.current || {};
+ const proposed = data.proposed || {};
+ const fields = Object.keys(proposed);
+ let rows = fields.map(f => \`
+
+ | \${esc(f)} |
+ \${esc(fmt(current[f]))} |
+ \${esc(fmt(proposed[f]))} |
+
+ \`).join("");
+ document.getElementById("review").innerHTML = \`
+ Metadata Changes
+
+ | Field | Current | Proposed |
+ \${rows}
+
+
+
+
+
+ \`;`,
+ });
}
diff --git a/src/main/engine/mcp-views/review-post.html b/src/main/engine/mcp-views/review-post.html
deleted file mode 100644
index d661b87..0000000
--- a/src/main/engine/mcp-views/review-post.html
+++ /dev/null
@@ -1,124 +0,0 @@
-
-
-Review Post
-
-
-
-
-
Waiting for post data...
-
-
-
-
-
diff --git a/src/main/engine/mcp-views/review-script.html b/src/main/engine/mcp-views/review-script.html
deleted file mode 100644
index f352bc5..0000000
--- a/src/main/engine/mcp-views/review-script.html
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-Review Script
-
-
-
-
-
Waiting for script data...
-
-
-
-
-
diff --git a/src/main/engine/mcp-views/review-template.html b/src/main/engine/mcp-views/review-template.html
deleted file mode 100644
index 7aa7a0d..0000000
--- a/src/main/engine/mcp-views/review-template.html
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-Review Template
-
-
-
-
-
Waiting for template data...
-
-
-
-
-
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index d703616..8c5cc0e 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -1612,10 +1612,15 @@ export function registerIpcHandlers(): void {
return null;
}
});
+}
- // ============ Event Forwarding ============
-
- // Forward engine events to renderer
+/**
+ * Register event forwarding from engine EventEmitters to the renderer via IPC.
+ * Must be called after the database is initialized (engines require DB access).
+ * Separated from registerIpcHandlers() so that handler registration can happen
+ * synchronously before any async work, eliminating startup race conditions.
+ */
+export function registerEventForwarding(): void {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const projectEngine = getProjectEngine();
diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts
index 1fc06f9..63d5753 100644
--- a/src/main/ipc/index.ts
+++ b/src/main/ipc/index.ts
@@ -1,2 +1,2 @@
-export { registerIpcHandlers } from './handlers';
+export { registerIpcHandlers, registerEventForwarding } from './handlers';
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';
diff --git a/src/main/main.ts b/src/main/main.ts
index 69c3a3c..2ef5730 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol
import * as path from 'path';
import * as fs from 'fs';
import { getDatabase } from './database';
-import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
+import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
import { media } from './database/schema';
import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine';
@@ -714,10 +714,19 @@ function createApplicationMenu(): Menu {
}
async function initialize(): Promise {
+ // Register IPC handlers immediately (synchronous) so they are available
+ // before any async work. This eliminates race conditions where the renderer
+ // calls handlers before the database is ready.
+ registerIpcHandlers();
+
// Initialize database
const db = getDatabase();
await db.initializeLocal();
+ // Now that the database is ready, register event forwarding from engines
+ // to the renderer (engines need DB access at registration time).
+ registerEventForwarding();
+
// Register custom protocol for serving media files
// URLs like bds-media://media-id will be resolved to the actual file
protocol.handle('bds-media', async (request) => {
@@ -816,9 +825,6 @@ async function initialize(): Promise {
}
});
- // Register IPC handlers
- registerIpcHandlers();
-
ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => {
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts
index 80e56f5..8bb4e62 100644
--- a/tests/engine/mainStartup.test.ts
+++ b/tests/engine/mainStartup.test.ts
@@ -91,6 +91,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -209,6 +210,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -356,6 +358,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -498,6 +501,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -628,6 +632,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -780,6 +785,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -961,6 +967,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -1145,6 +1152,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
+ registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
diff --git a/tests/engine/mcp-view-builder.test.ts b/tests/engine/mcp-view-builder.test.ts
new file mode 100644
index 0000000..be52622
--- /dev/null
+++ b/tests/engine/mcp-view-builder.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect } from 'vitest';
+import { buildMcpView, type McpViewConfig } from '../../src/main/engine/mcp-view-builder';
+
+describe('mcp-view-builder', () => {
+ describe('buildMcpView', () => {
+ const minimalConfig: McpViewConfig = {
+ title: 'Test View',
+ waitingMessage: 'Waiting for data...',
+ renderBody: `
+ document.getElementById("review").innerHTML = "Hello
";
+ `,
+ acceptLabel: 'Accept',
+ discardLabel: 'Discard',
+ };
+
+ it('returns a valid HTML document', () => {
+ const html = buildMcpView(minimalConfig);
+ expect(html).toContain('');
+ expect(html).toContain('