feat: more work on mcp server integration

This commit is contained in:
2026-02-28 12:36:13 +01:00
parent e71e478776
commit 6c22e69805
36 changed files with 1420 additions and 635 deletions

View File

@@ -169,7 +169,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
const match = candidates.find((candidate) => {
const createdAt = candidate.createdAt;
return createdAt.getFullYear() === dateFilter.year
&& createdAt.getMonth() === dateFilter.month;
&& createdAt.getMonth() === dateFilter.month - 1;
});
return match ?? null;

View File

@@ -0,0 +1,156 @@
/**
* MCPAgentConfigEngine adds the bDS MCP server entry to coding-agent config files.
*
* Supports: Claude Code, GitHub Copilot (VS Code), Gemini CLI, OpenCode.
* Each agent has its own config file format; this engine reads, merges, and writes
* the appropriate JSON structure without overwriting existing entries.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import path from 'path';
// ── Public types ─────────────────────────────────────────────────────
export type MCPAgentId = 'claude-code' | 'github-copilot' | 'gemini-cli' | 'opencode';
export interface AgentDefinition {
id: MCPAgentId;
label: string;
}
export interface AgentConfigResult {
success: boolean;
configPath: string;
error?: string;
}
export interface MCPAgentConfigOptions {
homeDir: string;
platform: NodeJS.Platform;
mcpUrl: string;
}
// ── Agent definitions ────────────────────────────────────────────────
const AGENTS: AgentDefinition[] = [
{ id: 'claude-code', label: 'Claude Code' },
{ id: 'github-copilot', label: 'GitHub Copilot' },
{ id: 'gemini-cli', label: 'Gemini CLI' },
{ id: 'opencode', label: 'OpenCode' },
];
const SERVER_NAME = 'bDS';
// ── Engine ───────────────────────────────────────────────────────────
export class MCPAgentConfigEngine {
private readonly homeDir: string;
private readonly platform: NodeJS.Platform;
private readonly mcpUrl: string;
constructor(opts: MCPAgentConfigOptions) {
this.homeDir = opts.homeDir;
this.platform = opts.platform;
this.mcpUrl = opts.mcpUrl;
}
/** Return the list of supported agent definitions. */
getAgents(): AgentDefinition[] {
return [...AGENTS];
}
/** Resolve the absolute path to the config file for the given agent. */
getConfigPath(agentId: MCPAgentId): string {
switch (agentId) {
case 'claude-code':
return path.join(this.homeDir, '.claude.json');
case 'github-copilot':
return this.vsCodeMcpPath();
case 'gemini-cli':
return path.join(this.homeDir, '.gemini', 'settings.json');
case 'opencode':
return path.join(this.homeDir, '.opencode.json');
}
}
/** Read-merge-write the bDS MCP server entry into the agent's config file. */
addToConfig(agentId: MCPAgentId): AgentConfigResult {
const configPath = this.getConfigPath(agentId);
try {
const existing = this.readExisting(configPath);
const merged = this.merge(agentId, existing);
this.ensureDir(configPath);
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
return { success: true, configPath };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, configPath, error: message };
}
}
/** Check whether the bDS entry already exists in the agent's config. */
isConfigured(agentId: MCPAgentId): boolean {
const configPath = this.getConfigPath(agentId);
if (!existsSync(configPath)) return false;
try {
const data = JSON.parse(readFileSync(configPath, 'utf-8'));
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
return !!data?.[serversKey]?.[SERVER_NAME];
} catch {
return false;
}
}
// ── Private helpers ──────────────────────────────────────────────
private vsCodeMcpPath(): string {
if (this.platform === 'darwin') {
return path.join(this.homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
}
if (this.platform === 'win32') {
return path.join(this.homeDir, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json');
}
// linux and others
return path.join(this.homeDir, '.config', 'Code', 'User', 'mcp.json');
}
private readExisting(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) return {};
const raw = readFileSync(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
}
private merge(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> {
const entry = this.buildEntry(agentId);
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {};
return {
...existing,
[serversKey]: {
...currentServers,
[SERVER_NAME]: entry,
},
};
}
private buildEntry(agentId: MCPAgentId): Record<string, unknown> {
switch (agentId) {
case 'claude-code':
return { type: 'http', url: this.mcpUrl };
case 'github-copilot':
return { type: 'http', url: this.mcpUrl };
case 'gemini-cli':
return { httpUrl: this.mcpUrl };
case 'opencode':
return { type: 'sse', url: this.mcpUrl };
}
}
private ensureDir(filePath: string): void {
const dir = path.dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}

View File

@@ -818,9 +818,9 @@ export class MediaEngine extends EventEmitter {
}
if (filter.month !== undefined && filter.year !== undefined) {
// Use UTC dates to avoid timezone issues
const startOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
const endOfMonth = new Date(Date.UTC(filter.year, filter.month + 1, 1));
// Use UTC dates to avoid timezone issues (filter.month is 1-indexed)
const startOfMonth = new Date(Date.UTC(filter.year, filter.month - 1, 1));
const endOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
console.log(`[MediaEngine] Month filter: ${startOfMonth.toISOString()} to ${endOfMonth.toISOString()}`);
conditions.push(gte(media.createdAt, startOfMonth));
conditions.push(lt(media.createdAt, endOfMonth));
@@ -912,7 +912,7 @@ export class MediaEngine extends EventEmitter {
for (const item of allMedia) {
const year = item.createdAt.getFullYear();
const month = item.createdAt.getMonth();
const month = item.createdAt.getMonth() + 1; // 1-indexed
const key = `${year}-${month}`;
const current = counts.get(key) || { year, month, count: 0 };
current.count++;

View File

@@ -1264,21 +1264,11 @@ export class OpenCodeManager {
const limit = (args.limit as number) || 10;
let filteredPosts;
if (hasFilters) {
// Combined FTS + structural filters in a single SQL query
filteredPosts = await this.postEngine.searchPostsFiltered(
args.query as string, filter, { offset, limit },
);
} else {
// Pure FTS search
const searchResults = await this.postEngine.searchPosts(args.query as string);
// searchPosts returns sparse results; fetch full post data
const fullPosts = await Promise.all(
searchResults.map(sr => this.postEngine.getPost(sr.id))
);
const all = fullPosts.filter(p => p !== null) as PostData[];
filteredPosts = all.slice(offset, offset + limit);
}
// Use searchPostsFiltered for all paths — it handles FTS + structural
// filters in a single SQL JOIN and returns full PostData[]
filteredPosts = await this.postEngine.searchPostsFiltered(
args.query as string, filter, { offset, limit },
);
const totalMatches = filteredPosts.length;
@@ -1320,7 +1310,7 @@ export class OpenCodeManager {
if (args.tags) filter.tags = args.tags as string[];
if (args.category) filter.categories = [args.category as string];
if (args.year !== undefined) filter.year = args.year as number;
if (args.month !== undefined && args.year !== undefined) filter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number;
const offset = (args.offset as number) || 0;
const limit = (args.limit as number) || 20;
@@ -1380,7 +1370,7 @@ export class OpenCodeManager {
if (hasMediaFilter) {
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
if (args.year !== undefined) mediaFilter.year = args.year as number;
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = args.month as number;
if (args.tags) mediaFilter.tags = args.tags as string[];
mediaList = await this.mediaEngine.getMediaFiltered(mediaFilter);
} else {

View File

@@ -770,8 +770,8 @@ export class PostEngine extends EventEmitter {
}
if (filter.month !== undefined && filter.year !== undefined) {
const startOfMonth = new Date(filter.year, filter.month, 1);
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
const startOfMonth = new Date(filter.year, filter.month - 1, 1);
const endOfMonth = new Date(filter.year, filter.month, 1);
conditions.push(gte(posts.createdAt, startOfMonth));
conditions.push(lte(posts.createdAt, endOfMonth));
}
@@ -1126,7 +1126,7 @@ export class PostEngine extends EventEmitter {
for (const post of allPosts) {
const year = post.createdAt.getFullYear();
const month = post.createdAt.getMonth();
const month = post.createdAt.getMonth() + 1; // 1-indexed
const key = `${year}-${month}`;
const current = counts.get(key) || { year, month, count: 0 };
current.count++;

View File

@@ -180,7 +180,7 @@ async function resolveRouteWithSharedServices(
const month = Number(daySlugMatch[2]);
const day = Number(daySlugMatch[3]);
const slug = daySlugMatch[4];
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
if (!post) return null;
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
@@ -224,7 +224,7 @@ async function resolveRouteWithSharedServices(
const year = Number(monthMatch[1]);
const month = Number(monthMatch[2]);
if (month < 1 || month > 12) return null;
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month, excludeCategories: listExcludedCategories }, pageOptions);
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
archiveGrouping: true,
routeKind: 'date',

View File

@@ -177,7 +177,7 @@ export async function findSinglePostBySlug(
}
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
return draftCandidate;

View File

@@ -1,5 +1,5 @@
/**
* MCP App Review Views — inline HTML strings for review UIs.
* MCP App Review Views — loaded from HTML files in the `mcp-views/` directory.
*
* Each function returns a self-contained HTML page that uses the
* `App` class from `@modelcontextprotocol/ext-apps` (loaded via
@@ -9,250 +9,77 @@
* in MCP hosts that support MCP Apps (Claude, ChatGPT, VS Code, etc.).
*/
function baseStyles(): string {
return `
* { 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; }
.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; }
.word-count { color: #888; font-size: 0.8rem; }
`;
import { readFileSync } from 'fs';
import path from 'path';
/**
* 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;
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));
}
function appScript(): string {
return `
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);
/**
* 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
}
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 || (() => {});
app.connect().catch(e => console.error("App connect failed:", e));
`;
}
throw new Error(
`MCP view "${filename}" not found in any of: ${dirs.join(', ')}`,
);
}
export function reviewPostHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Post</title>
<style>${baseStyles()}</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">
${appScript()}
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; }
</script>
</body>
</html>`;
export function reviewPostHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[0],
): string {
return loadViewHtml('review-post.html', options);
}
export function reviewScriptHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Script</title>
<style>${baseStyles()}</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">
${appScript()}
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; }
</script>
</body>
</html>`;
export function reviewScriptHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[0],
): string {
return loadViewHtml('review-script.html', options);
}
export function reviewTemplateHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Template</title>
<style>${baseStyles()}</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">
${appScript()}
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; }
</script>
</body>
</html>`;
export function reviewTemplateHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[0],
): string {
return loadViewHtml('review-template.html', options);
}
export function reviewMetadataHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Metadata Changes</title>
<style>${baseStyles()}</style>
</head>
<body>
<div id="review">
<p class="meta">Waiting for metadata data...</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script type="module">
${appScript()}
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>
\`;
};
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }
</script>
</body>
</html>`;
export function reviewMetadataHtml(
options?: Parameters<typeof resolveMcpViewsDirs>[0],
): string {
return loadViewHtml('review-metadata.html', options);
}

View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Review Metadata</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; }
.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">
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 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>
`;
};
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

@@ -0,0 +1,124 @@
<!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

@@ -0,0 +1,116 @@
<!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

@@ -0,0 +1,116 @@
<!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

@@ -125,6 +125,16 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
}
}
function buildMcpUrl(): string {
try {
const { getMCPServer } = require('../engine/MCPServer');
const port = getMCPServer().getPort() ?? 4124;
return `http://127.0.0.1:${port}/mcp`;
} catch {
return 'http://127.0.0.1:4124/mcp';
}
}
export function registerIpcHandlers(): void {
// ============ Git Handlers ============
@@ -1562,6 +1572,47 @@ export function registerIpcHandlers(): void {
registerBlogHandlers(safeHandle);
registerPublishHandlers(safeHandle);
// ============ MCP Config Handlers ============
safeHandle('mcp:getAgents', async () => {
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
const engine = new MCPAgentConfigEngine({
homeDir: require('os').homedir(),
platform: process.platform,
mcpUrl: buildMcpUrl(),
});
return engine.getAgents();
});
safeHandle('mcp:addToAgentConfig', async (_event: unknown, agentId: string) => {
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
const engine = new MCPAgentConfigEngine({
homeDir: require('os').homedir(),
platform: process.platform,
mcpUrl: buildMcpUrl(),
});
return engine.addToConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
});
safeHandle('mcp:isConfigured', async (_event: unknown, agentId: string) => {
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
const engine = new MCPAgentConfigEngine({
homeDir: require('os').homedir(),
platform: process.platform,
mcpUrl: buildMcpUrl(),
});
return engine.isConfigured(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
});
safeHandle('mcp:getPort', async () => {
try {
const { getMCPServer } = await import('../engine/MCPServer');
return getMCPServer().getPort();
} catch {
return null;
}
});
// ============ Event Forwarding ============
// Forward engine events to renderer

View File

@@ -382,6 +382,13 @@ export const electronAPI: ElectronAPI = {
once: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
},
mcp: {
getAgents: () => ipcRenderer.invoke('mcp:getAgents'),
addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId),
isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId),
getPort: () => ipcRenderer.invoke('mcp:getPort'),
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -836,5 +836,11 @@ export interface ElectronAPI {
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
mcp: {
getAgents: () => Promise<Array<{ id: string; label: string }>>;
addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>;
isConfigured: (agentId: string) => Promise<boolean>;
getPort: () => Promise<number | null>;
};
}