feat: rework templates

This commit is contained in:
2026-02-28 13:00:51 +01:00
parent 6c22e69805
commit 46752068be
12 changed files with 363 additions and 526 deletions

View File

@@ -152,10 +152,6 @@
{
"from": "src/main/engine/templates",
"to": "templates"
},
{
"from": "src/main/engine/mcp-views",
"to": "mcp-views"
}
],
"protocols": [

View File

@@ -1,7 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Metadata</title>
<style>
/**
* MCP App Review View Builder generates self-contained HTML pages
* from shared boilerplate + per-view configuration.
*
* Each generated page uses the `App` class from
* `@modelcontextprotocol/ext-apps` (loaded via the `app-with-deps` bundle)
* and is served as a `ui://` resource for MCP hosts.
*
* This replaces the previous approach of 4 separate HTML files that
* duplicated ~80% of their content (CSS, JS boilerplate, accept/discard
* handlers, status display, XSS escaping, app connection).
*/
/** Configuration for a single MCP review view. */
export interface McpViewConfig {
/** Page <title>. */
title: string;
/** Text shown in the review area before data arrives. */
waitingMessage: string;
/**
* The body of the `window.renderReview = (data) => { ... }` function.
* Has access to `data`, `esc()`, `document`, and any helpers defined
* in `extraJsHelpers`. Must set `document.getElementById("review").innerHTML`.
*/
renderBody: string;
/** Label for the accept/confirm button (e.g. "Publish", "Create Template"). */
acceptLabel: string;
/** Label for the discard/cancel button (e.g. "Discard", "Discard Draft"). */
discardLabel: string;
/** Additional CSS rules appended after the shared stylesheet. */
extraCss?: string;
/** Additional JS helper functions placed before `renderReview`. */
extraJsHelpers?: string;
}
/* ── Shared CSS ─────────────────────────────────────────────────────── */
const SHARED_CSS = `\
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
h1 { font-size: 1.25rem; margin-bottom: 12px; }
@@ -21,20 +55,11 @@
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
.status-success { background: #d4edda; color: #155724; }
.status-error { background: #f8d7da; color: #721c24; }
.word-count { color: #888; font-size: 0.8rem; }
.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; }
</style>
</head>
<body>
<div id="review">
<p class="meta">Waiting for metadata...</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script type="module">
.word-count { color: #888; font-size: 0.8rem; }`;
/* ── Shared JS ──────────────────────────────────────────────────────── */
const SHARED_JS = `\
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
const app = new App({ name: "bDS Review", version: "1.0.0" });
@@ -99,34 +124,42 @@
}
window.showStatus = showStatus;
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }`;
/* ── Builder ────────────────────────────────────────────────────────── */
/**
* Build a self-contained HTML page for an MCP review view.
*
* The output is a complete `<!DOCTYPE html>` document that can be served
* directly as a `ui://` resource.
*/
export function buildMcpView(config: McpViewConfig): string {
const extraCss = config.extraCss ? `\n${config.extraCss}` : '';
const extraJs = config.extraJsHelpers ? `\n ${config.extraJsHelpers}` : '';
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>${config.title}</title>
<style>
${SHARED_CSS}${extraCss}
</style>
</head>
<body>
<div id="review">
<p class="meta">${config.waitingMessage}</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script type="module">
${SHARED_JS}${extraJs}
window.renderReview = (data) => {
const current = data.current || {};
const proposed = data.proposed || {};
const fields = Object.keys(proposed);
let rows = fields.map(f => `
<tr>
<td><strong>${esc(f)}</strong></td>
<td class="diff-old">${esc(fmt(current[f]))}</td>
<td class="diff-new">${esc(fmt(proposed[f]))}</td>
</tr>
`).join("");
document.getElementById("review").innerHTML = `
<h1>Metadata Changes</h1>
<table class="diff-table">
<thead><tr><th>Field</th><th>Current</th><th>Proposed</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Apply Changes</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
`;
${config.renderBody}
};
function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
app.connect().catch(e => console.error("App connect failed:", e));
</script>
</body>
</html>
`;
}

View File

@@ -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 = \`
<h1>\${esc(post.title || "Untitled")}</h1>
<p class="meta">
<span class="badge badge-draft">Draft</span>
<span class="word-count">\${wc} words</span>
</p>
\${post.categories?.length ? '<p class="meta">Categories: ' + post.categories.map(c => esc(c)).join(", ") + '</p>' : ''}
\${post.tags?.length ? '<p class="meta">Tags: ' + post.tags.map(t => esc(t)).join(", ") + '</p>' : ''}
\${post.excerpt ? '<h2>Excerpt</h2><p>' + esc(post.excerpt) + '</p>' : ''}
<h2>Content</h2>
<div class="content-preview">\${esc(post.content || "")}</div>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Publish</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard Draft</button>
</div>
\`;`,
});
}
/**
* 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<typeof resolveMcpViewsDirs>[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 = \`
<h1>\${esc(p.title || "Untitled Script")}</h1>
<p class="meta"><span class="badge badge-kind">\${esc(p.kind || "script")}</span></p>
<h2>Python Code</h2>
<div class="content-preview">\${esc(p.content || "(code not included in preview)")}</div>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Create Script</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
\`;`,
});
}
export function reviewPostHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[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 = \`
<h1>\${esc(p.title || "Untitled Template")}</h1>
<p class="meta"><span class="badge badge-kind">\${esc(p.kind || "template")}</span></p>
<h2>Liquid Template</h2>
<div class="content-preview">\${esc(p.content || "(template not included in preview)")}</div>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Create Template</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
\`;`,
});
}
export function reviewScriptHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[0],
): string {
return loadViewHtml('review-script.html', options);
}
/* ── Review Metadata ────────────────────────────────────────────────── */
export function reviewTemplateHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[0],
): string {
return loadViewHtml('review-template.html', options);
}
export function reviewMetadataHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[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 => \`
<tr>
<td><strong>\${esc(f)}</strong></td>
<td class="diff-old">\${esc(fmt(current[f]))}</td>
<td class="diff-new">\${esc(fmt(proposed[f]))}</td>
</tr>
\`).join("");
document.getElementById("review").innerHTML = \`
<h1>Metadata Changes</h1>
<table class="diff-table">
<thead><tr><th>Field</th><th>Current</th><th>Proposed</th></tr></thead>
<tbody>\${rows}</tbody>
</table>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Apply Changes</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
\`;`,
});
}

View File

@@ -1,124 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Post</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
h1 { font-size: 1.25rem; margin-bottom: 12px; }
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.badge-draft { background: #fef3cd; color: #856404; }
.badge-kind { background: #d1ecf1; color: #0c5460; }
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
.actions { display: flex; gap: 8px; margin-top: 16px; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
.btn-accept { background: #28a745; color: #fff; }
.btn-accept:hover { background: #218838; }
.btn-discard { background: #dc3545; color: #fff; }
.btn-discard:hover { background: #c82333; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
.status-success { background: #d4edda; color: #155724; }
.status-error { background: #f8d7da; color: #721c24; }
.word-count { color: #888; font-size: 0.8rem; }
</style>
</head>
<body>
<div id="review">
<p class="meta">Waiting for post data...</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script type="module">
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
const app = new App({ name: "bDS Review", version: "1.0.0" });
let currentData = null;
app.ontoolresult = (result) => {
try {
const textContent = result.content?.find(c => c.type === "text");
if (textContent?.text) {
currentData = JSON.parse(textContent.text);
renderReview(currentData);
}
} catch (e) {
showStatus("Failed to parse tool result: " + e.message, "error");
}
};
window.acceptProposal = async () => {
if (!currentData?.proposalId) return;
setButtonsDisabled(true);
try {
const result = await app.callServerTool({
name: "accept_proposal",
arguments: { proposalId: currentData.proposalId }
});
const text = result.content?.find(c => c.type === "text")?.text;
const parsed = text ? JSON.parse(text) : {};
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
} catch (e) {
showStatus("Error: " + e.message, "error");
}
};
window.discardProposal = async () => {
if (!currentData?.proposalId) return;
setButtonsDisabled(true);
try {
const result = await app.callServerTool({
name: "discard_proposal",
arguments: { proposalId: currentData.proposalId }
});
const text = result.content?.find(c => c.type === "text")?.text;
const parsed = text ? JSON.parse(text) : {};
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
} catch (e) {
showStatus("Error: " + e.message, "error");
}
};
function setButtonsDisabled(disabled) {
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
}
function showStatus(message, type) {
const el = document.getElementById("status");
if (el) {
el.textContent = message;
el.className = "status status-" + type;
el.style.display = "block";
}
}
window.showStatus = showStatus;
window.renderReview = window.renderReview || (() => {});
window.renderReview = (data) => {
const post = data.post || {};
const wc = (post.content || "").split(/\s+/).filter(Boolean).length;
document.getElementById("review").innerHTML = `
<h1>${esc(post.title || "Untitled")}</h1>
<p class="meta">
<span class="badge badge-draft">Draft</span>
<span class="word-count">${wc} words</span>
</p>
${post.categories?.length ? '<p class="meta">Categories: ' + post.categories.map(c => esc(c)).join(", ") + '</p>' : ''}
${post.tags?.length ? '<p class="meta">Tags: ' + post.tags.map(t => esc(t)).join(", ") + '</p>' : ''}
${post.excerpt ? '<h2>Excerpt</h2><p>' + esc(post.excerpt) + '</p>' : ''}
<h2>Content</h2>
<div class="content-preview">${esc(post.content || "")}</div>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Publish</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard Draft</button>
</div>
`;
};
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
app.connect().catch(e => console.error("App connect failed:", e));
</script>
</body>
</html>

View File

@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Script</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
h1 { font-size: 1.25rem; margin-bottom: 12px; }
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.badge-draft { background: #fef3cd; color: #856404; }
.badge-kind { background: #d1ecf1; color: #0c5460; }
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
.actions { display: flex; gap: 8px; margin-top: 16px; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
.btn-accept { background: #28a745; color: #fff; }
.btn-accept:hover { background: #218838; }
.btn-discard { background: #dc3545; color: #fff; }
.btn-discard:hover { background: #c82333; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
.status-success { background: #d4edda; color: #155724; }
.status-error { background: #f8d7da; color: #721c24; }
.word-count { color: #888; font-size: 0.8rem; }
</style>
</head>
<body>
<div id="review">
<p class="meta">Waiting for script data...</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script type="module">
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
const app = new App({ name: "bDS Review", version: "1.0.0" });
let currentData = null;
app.ontoolresult = (result) => {
try {
const textContent = result.content?.find(c => c.type === "text");
if (textContent?.text) {
currentData = JSON.parse(textContent.text);
renderReview(currentData);
}
} catch (e) {
showStatus("Failed to parse tool result: " + e.message, "error");
}
};
window.acceptProposal = async () => {
if (!currentData?.proposalId) return;
setButtonsDisabled(true);
try {
const result = await app.callServerTool({
name: "accept_proposal",
arguments: { proposalId: currentData.proposalId }
});
const text = result.content?.find(c => c.type === "text")?.text;
const parsed = text ? JSON.parse(text) : {};
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
} catch (e) {
showStatus("Error: " + e.message, "error");
}
};
window.discardProposal = async () => {
if (!currentData?.proposalId) return;
setButtonsDisabled(true);
try {
const result = await app.callServerTool({
name: "discard_proposal",
arguments: { proposalId: currentData.proposalId }
});
const text = result.content?.find(c => c.type === "text")?.text;
const parsed = text ? JSON.parse(text) : {};
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
} catch (e) {
showStatus("Error: " + e.message, "error");
}
};
function setButtonsDisabled(disabled) {
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
}
function showStatus(message, type) {
const el = document.getElementById("status");
if (el) {
el.textContent = message;
el.className = "status status-" + type;
el.style.display = "block";
}
}
window.showStatus = showStatus;
window.renderReview = (data) => {
const p = data.preview || data;
document.getElementById("review").innerHTML = `
<h1>${esc(p.title || "Untitled Script")}</h1>
<p class="meta"><span class="badge badge-kind">${esc(p.kind || "script")}</span></p>
<h2>Python Code</h2>
<div class="content-preview">${esc(p.content || "(code not included in preview)")}</div>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Create Script</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
`;
};
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
app.connect().catch(e => console.error("App connect failed:", e));
</script>
</body>
</html>

View File

@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Template</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
h1 { font-size: 1.25rem; margin-bottom: 12px; }
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.badge-draft { background: #fef3cd; color: #856404; }
.badge-kind { background: #d1ecf1; color: #0c5460; }
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
.actions { display: flex; gap: 8px; margin-top: 16px; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
.btn-accept { background: #28a745; color: #fff; }
.btn-accept:hover { background: #218838; }
.btn-discard { background: #dc3545; color: #fff; }
.btn-discard:hover { background: #c82333; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
.status-success { background: #d4edda; color: #155724; }
.status-error { background: #f8d7da; color: #721c24; }
.word-count { color: #888; font-size: 0.8rem; }
</style>
</head>
<body>
<div id="review">
<p class="meta">Waiting for template data...</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script type="module">
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
const app = new App({ name: "bDS Review", version: "1.0.0" });
let currentData = null;
app.ontoolresult = (result) => {
try {
const textContent = result.content?.find(c => c.type === "text");
if (textContent?.text) {
currentData = JSON.parse(textContent.text);
renderReview(currentData);
}
} catch (e) {
showStatus("Failed to parse tool result: " + e.message, "error");
}
};
window.acceptProposal = async () => {
if (!currentData?.proposalId) return;
setButtonsDisabled(true);
try {
const result = await app.callServerTool({
name: "accept_proposal",
arguments: { proposalId: currentData.proposalId }
});
const text = result.content?.find(c => c.type === "text")?.text;
const parsed = text ? JSON.parse(text) : {};
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
} catch (e) {
showStatus("Error: " + e.message, "error");
}
};
window.discardProposal = async () => {
if (!currentData?.proposalId) return;
setButtonsDisabled(true);
try {
const result = await app.callServerTool({
name: "discard_proposal",
arguments: { proposalId: currentData.proposalId }
});
const text = result.content?.find(c => c.type === "text")?.text;
const parsed = text ? JSON.parse(text) : {};
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
} catch (e) {
showStatus("Error: " + e.message, "error");
}
};
function setButtonsDisabled(disabled) {
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
}
function showStatus(message, type) {
const el = document.getElementById("status");
if (el) {
el.textContent = message;
el.className = "status status-" + type;
el.style.display = "block";
}
}
window.showStatus = showStatus;
window.renderReview = (data) => {
const p = data.preview || data;
document.getElementById("review").innerHTML = `
<h1>${esc(p.title || "Untitled Template")}</h1>
<p class="meta"><span class="badge badge-kind">${esc(p.kind || "template")}</span></p>
<h2>Liquid Template</h2>
<div class="content-preview">${esc(p.content || "(template not included in preview)")}</div>
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Create Template</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
`;
};
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
app.connect().catch(e => console.error("App connect failed:", e));
</script>
</body>
</html>

View File

@@ -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();

View File

@@ -1,2 +1,2 @@
export { registerIpcHandlers } from './handlers';
export { registerIpcHandlers, registerEventForwarding } from './handlers';
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';

View File

@@ -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<void> {
// 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<void> {
}
});
// Register IPC handlers
registerIpcHandlers();
ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => {
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));

View File

@@ -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),

View File

@@ -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 = "<h1>Hello</h1>";
`,
acceptLabel: 'Accept',
discardLabel: 'Discard',
};
it('returns a valid HTML document', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('sets the page title', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('<title>Test View</title>');
});
it('contains the waiting message', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('Waiting for data...');
});
it('contains the App import from ext-apps', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
expect(html).toContain('new App(');
});
it('contains accept and discard proposal handlers', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('window.acceptProposal');
expect(html).toContain('window.discardProposal');
});
it('uses custom button labels in renderBody', () => {
const config: McpViewConfig = {
...minimalConfig,
renderBody: `
document.getElementById("review").innerHTML = \`
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Accept</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
\`;`,
};
const html = buildMcpView(config);
expect(html).toContain('onclick="acceptProposal()"');
expect(html).toContain('onclick="discardProposal()"');
});
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('app.callServerTool');
expect(html).toContain('"accept_proposal"');
expect(html).toContain('"discard_proposal"');
});
it('contains the custom renderBody code', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('document.getElementById("review").innerHTML = "<h1>Hello</h1>"');
});
it('uses module script type', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('type="module"');
});
it('connects the App on load', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('app.connect()');
});
it('has a status display element', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('id="status"');
expect(html).toContain('showStatus');
});
it('disables buttons during action', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('setButtonsDisabled(true)');
});
it('uses XSS-safe escaping function', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('function esc(');
expect(html).toContain('document.createElement("div")');
});
it('renders tool result data via ontoolresult handler', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('app.ontoolresult');
expect(html).toContain('renderReview');
});
it('includes extra CSS when provided', () => {
const config: McpViewConfig = {
...minimalConfig,
extraCss: '.custom-class { color: red; }',
};
const html = buildMcpView(config);
expect(html).toContain('.custom-class { color: red; }');
});
it('includes extra JS helpers when provided', () => {
const config: McpViewConfig = {
...minimalConfig,
extraJsHelpers: 'function fmt(v) { return String(v); }',
};
const html = buildMcpView(config);
expect(html).toContain('function fmt(v) { return String(v); }');
});
it('does not include extra CSS/JS when not provided', () => {
const html = buildMcpView(minimalConfig);
// The shared CSS is always present; just ensure no extra markers
expect(html).not.toContain('function fmt(');
});
});
});

View File

@@ -1,68 +1,40 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import {
reviewPostHtml,
reviewScriptHtml,
reviewTemplateHtml,
reviewMetadataHtml,
resolveMcpViewsDirs,
loadViewHtml,
} from '../../src/main/engine/mcp-views';
const viewOpts = {
moduleDir: path.resolve(__dirname, '../../src/main/engine'),
};
describe('mcp-views', () => {
describe('resolveMcpViewsDirs', () => {
it('returns candidate directories', () => {
const dirs = resolveMcpViewsDirs(viewOpts);
expect(dirs.length).toBeGreaterThanOrEqual(2);
expect(dirs.some(d => d.includes('mcp-views'))).toBe(true);
});
});
describe('loadViewHtml', () => {
it('loads an existing view file', () => {
const html = loadViewHtml('review-post.html', viewOpts);
expect(html).toContain('<!DOCTYPE html>');
});
it('throws for a non-existent view', () => {
expect(() => loadViewHtml('does-not-exist.html', viewOpts)).toThrow(
/not found/,
);
});
});
describe('reviewPostHtml', () => {
it('returns valid HTML document', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
expect(html).toContain('new App(');
});
it('contains accept and discard buttons', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('app.callServerTool');
expect(html).toContain('"accept_proposal"');
expect(html).toContain('"discard_proposal"');
});
it('contains post-specific UI elements', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('Review Post');
expect(html).toContain('Publish');
expect(html).toContain('badge-draft');
@@ -70,13 +42,13 @@ describe('mcp-views', () => {
});
it('renders tool result data via ontoolresult handler', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('app.ontoolresult');
expect(html).toContain('renderReview');
});
it('uses XSS-safe escaping function', () => {
const html = reviewPostHtml(viewOpts);
const html = reviewPostHtml();
expect(html).toContain('function esc(');
expect(html).toContain('document.createElement("div")');
});
@@ -84,24 +56,24 @@ describe('mcp-views', () => {
describe('reviewScriptHtml', () => {
it('returns valid HTML document', () => {
const html = reviewScriptHtml(viewOpts);
const html = reviewScriptHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewScriptHtml(viewOpts);
const html = reviewScriptHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
});
it('contains accept and discard buttons', () => {
const html = reviewScriptHtml(viewOpts);
const html = reviewScriptHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains script-specific UI elements', () => {
const html = reviewScriptHtml(viewOpts);
const html = reviewScriptHtml();
expect(html).toContain('Review Script');
expect(html).toContain('Create Script');
expect(html).toContain('Python Code');
@@ -110,24 +82,24 @@ describe('mcp-views', () => {
describe('reviewTemplateHtml', () => {
it('returns valid HTML document', () => {
const html = reviewTemplateHtml(viewOpts);
const html = reviewTemplateHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewTemplateHtml(viewOpts);
const html = reviewTemplateHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
});
it('contains accept and discard buttons', () => {
const html = reviewTemplateHtml(viewOpts);
const html = reviewTemplateHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains template-specific UI elements', () => {
const html = reviewTemplateHtml(viewOpts);
const html = reviewTemplateHtml();
expect(html).toContain('Review Template');
expect(html).toContain('Create Template');
expect(html).toContain('Liquid Template');
@@ -136,24 +108,24 @@ describe('mcp-views', () => {
describe('reviewMetadataHtml', () => {
it('returns valid HTML document', () => {
const html = reviewMetadataHtml(viewOpts);
const html = reviewMetadataHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewMetadataHtml(viewOpts);
const html = reviewMetadataHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
});
it('contains accept and discard buttons', () => {
const html = reviewMetadataHtml(viewOpts);
const html = reviewMetadataHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains metadata-diff UI elements', () => {
const html = reviewMetadataHtml(viewOpts);
const html = reviewMetadataHtml();
expect(html).toContain('Metadata Changes');
expect(html).toContain('Apply Changes');
expect(html).toContain('diff-table');
@@ -162,7 +134,7 @@ describe('mcp-views', () => {
});
it('contains diff formatting function', () => {
const html = reviewMetadataHtml(viewOpts);
const html = reviewMetadataHtml();
expect(html).toContain('function fmt(');
expect(html).toContain('diff-old');
expect(html).toContain('diff-new');
@@ -171,10 +143,10 @@ describe('mcp-views', () => {
describe('shared behavior', () => {
const allViews = [
{ name: 'reviewPostHtml', fn: () => reviewPostHtml(viewOpts) },
{ name: 'reviewScriptHtml', fn: () => reviewScriptHtml(viewOpts) },
{ name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml(viewOpts) },
{ name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml(viewOpts) },
{ name: 'reviewPostHtml', fn: () => reviewPostHtml() },
{ name: 'reviewScriptHtml', fn: () => reviewScriptHtml() },
{ name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml() },
{ name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml() },
];
it.each(allViews)('$name connects the App on load', ({ fn }) => {