feat: first cut at mcp and mcp apps
This commit is contained in:
690
src/main/engine/MCPServer.ts
Normal file
690
src/main/engine/MCPServer.ts
Normal file
@@ -0,0 +1,690 @@
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
registerAppTool,
|
||||
registerAppResource,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from '@modelcontextprotocol/ext-apps/server';
|
||||
import { createServer as createHttpServer, type Server } from 'http';
|
||||
import { z } from 'zod';
|
||||
import { ProposalStore, type ProposalType } from './ProposalStore';
|
||||
import {
|
||||
reviewPostHtml,
|
||||
reviewScriptHtml,
|
||||
reviewTemplateHtml,
|
||||
reviewMetadataHtml,
|
||||
} from './mcp-views';
|
||||
|
||||
// ── Dependency contracts ──────────────────────────────────────────────
|
||||
|
||||
interface PostEngineContract {
|
||||
getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
||||
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
searchPosts: (query: string) => Promise<Array<{ id: string; title: string; slug: string; excerpt?: string }>>;
|
||||
createPost: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
updatePost: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
||||
publishPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
deletePost: (id: string) => Promise<boolean>;
|
||||
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
|
||||
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
|
||||
getBlogStats: () => Promise<Record<string, unknown>>;
|
||||
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||
getPostsFiltered: (filter: Record<string, unknown>) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
interface MediaEngineContract {
|
||||
getAllMedia: () => Promise<Array<Record<string, unknown>>>;
|
||||
getMedia: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
updateMedia: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
interface ScriptEngineContract {
|
||||
createScript: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface TemplateEngineContract {
|
||||
createTemplate: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface MetaEngineContract {
|
||||
getProjectMetadata: () => Promise<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
interface PostMediaEngineContract {
|
||||
getLinkedMediaDataForPost: (postId: string) => Promise<Array<Record<string, unknown>>>;
|
||||
getLinkedPostsForMedia: (mediaId: string) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
interface TagEngineContract {
|
||||
getTagsWithCounts: () => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
export interface MCPServerDependencies {
|
||||
getPostEngine: () => PostEngineContract;
|
||||
getMediaEngine: () => MediaEngineContract;
|
||||
getScriptEngine: () => ScriptEngineContract;
|
||||
getTemplateEngine: () => TemplateEngineContract;
|
||||
getMetaEngine: () => MetaEngineContract;
|
||||
getPostMediaEngine: () => PostMediaEngineContract;
|
||||
getTagEngine: () => TagEngineContract;
|
||||
}
|
||||
|
||||
// ── MCPServer engine ──────────────────────────────────────────────────
|
||||
|
||||
export class MCPServer {
|
||||
readonly proposalStore: ProposalStore;
|
||||
private readonly deps: MCPServerDependencies;
|
||||
private httpServer: Server | null = null;
|
||||
private port: number | null = null;
|
||||
|
||||
constructor(deps: MCPServerDependencies) {
|
||||
this.deps = deps;
|
||||
this.proposalStore = new ProposalStore();
|
||||
}
|
||||
|
||||
/** Create a fresh McpServer with all tools/resources/prompts registered (stateless — one per request). */
|
||||
createMcpServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'Blogging Desktop Server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
this.registerResources(server);
|
||||
this.registerResourceTemplates(server);
|
||||
this.registerReadTools(server);
|
||||
this.registerProposalTools(server);
|
||||
this.registerAcceptDiscardTools(server);
|
||||
this.registerPrompts(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ── Server lifecycle ────────────────────────────────────────────────
|
||||
|
||||
async start(preferredPort = 4124): Promise<number> {
|
||||
if (this.httpServer && this.port !== null) {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
const app = createHttpServer(async (req, res) => {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpServer = this.createMcpServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // stateless
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
transport.close().catch(() => {});
|
||||
mcpServer.close().catch(() => {});
|
||||
});
|
||||
|
||||
try {
|
||||
await mcpServer.connect(transport);
|
||||
await transport.handleRequest(req, res, await parseBody(req));
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32603, message: 'Internal server error' },
|
||||
id: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
app.once('error', reject);
|
||||
app.listen(preferredPort, '127.0.0.1', () => {
|
||||
app.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.httpServer = app;
|
||||
const address = app.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to get MCP server address');
|
||||
}
|
||||
this.port = address.port;
|
||||
return this.port;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.httpServer) {
|
||||
this.port = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.httpServer) { resolve(); return; }
|
||||
this.httpServer.close((error) => {
|
||||
if (error) { reject(error); return; }
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.httpServer = null;
|
||||
this.port = null;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.proposalStore.destroy();
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
getPort(): number | null {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
// ── Accept / Discard ────────────────────────────────────────────────
|
||||
|
||||
async acceptProposal(proposalId: string): Promise<{ success: boolean; message: string }> {
|
||||
const proposal = this.proposalStore.get(proposalId);
|
||||
if (!proposal) {
|
||||
return { success: false, message: `Proposal ${proposalId} not found.` };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (proposal.type) {
|
||||
case 'draftPost': {
|
||||
const postId = proposal.data.postId as string;
|
||||
await this.deps.getPostEngine().publishPost(postId);
|
||||
break;
|
||||
}
|
||||
case 'proposeScript': {
|
||||
await this.deps.getScriptEngine().createScript(proposal.data);
|
||||
break;
|
||||
}
|
||||
case 'proposeTemplate': {
|
||||
await this.deps.getTemplateEngine().createTemplate(proposal.data);
|
||||
break;
|
||||
}
|
||||
case 'proposeMediaMetadata': {
|
||||
const mediaId = proposal.data.mediaId as string;
|
||||
const changes = proposal.data.changes as Record<string, unknown>;
|
||||
await this.deps.getMediaEngine().updateMedia(mediaId, changes);
|
||||
break;
|
||||
}
|
||||
case 'proposePostMetadata': {
|
||||
const postId = proposal.data.postId as string;
|
||||
const changes = proposal.data.changes as Record<string, unknown>;
|
||||
await this.deps.getPostEngine().updatePost(postId, changes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.proposalStore.remove(proposalId);
|
||||
return { success: true, message: `Proposal ${proposalId} accepted.` };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to accept proposal: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async discardProposal(proposalId: string): Promise<{ success: boolean; message: string }> {
|
||||
const proposal = this.proposalStore.get(proposalId);
|
||||
if (!proposal) {
|
||||
return { success: false, message: `Proposal ${proposalId} not found.` };
|
||||
}
|
||||
|
||||
try {
|
||||
if (proposal.type === 'draftPost') {
|
||||
const postId = proposal.data.postId as string;
|
||||
await this.deps.getPostEngine().deletePost(postId);
|
||||
}
|
||||
this.proposalStore.remove(proposalId);
|
||||
return { success: true, message: `Proposal ${proposalId} discarded.` };
|
||||
} catch (error) {
|
||||
return { success: false, message: `Failed to discard proposal: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resource registration ──────────────────────────────────────────
|
||||
|
||||
private registerResources(server: McpServer): void {
|
||||
server.registerResource('posts', 'bds://posts', { description: 'All blog posts' }, async () => {
|
||||
const result = await this.deps.getPostEngine().getAllPosts();
|
||||
return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('media', 'bds://media', { description: 'All media files' }, async () => {
|
||||
const result = await this.deps.getMediaEngine().getAllMedia();
|
||||
return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => {
|
||||
const result = await this.deps.getPostEngine().getTagsWithCounts();
|
||||
return { contents: [{ uri: 'bds://tags', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('categories', 'bds://categories', { description: 'Categories with post counts' }, async () => {
|
||||
const result = await this.deps.getPostEngine().getCategoriesWithCounts();
|
||||
return { contents: [{ uri: 'bds://categories', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('stats', 'bds://stats', { description: 'Blog statistics' }, async () => {
|
||||
const result = await this.deps.getPostEngine().getBlogStats();
|
||||
return { contents: [{ uri: 'bds://stats', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
}
|
||||
|
||||
private registerResourceTemplates(server: McpServer): void {
|
||||
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
||||
const result = await this.deps.getPostEngine().getPost(id as string);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('media-item', new ResourceTemplate('bds://media/{id}', { list: undefined }), { description: 'A single media item by ID' }, async (uri, { id }) => {
|
||||
const result = await this.deps.getMediaEngine().getMedia(id as string);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('post-backlinks', new ResourceTemplate('bds://posts/{id}/backlinks', { list: undefined }), { description: 'Posts linking to this post' }, async (uri, { id }) => {
|
||||
const result = await this.deps.getPostEngine().getLinkedBy(id as string);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('post-outlinks', new ResourceTemplate('bds://posts/{id}/outlinks', { list: undefined }), { description: 'Posts this post links to' }, async (uri, { id }) => {
|
||||
const result = await this.deps.getPostEngine().getLinksTo(id as string);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('post-media', new ResourceTemplate('bds://posts/{id}/media', { list: undefined }), { description: 'Media linked to a post' }, async (uri, { id }) => {
|
||||
const result = await this.deps.getPostMediaEngine().getLinkedMediaDataForPost(id as string);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerResource('media-posts', new ResourceTemplate('bds://media/{id}/posts', { list: undefined }), { description: 'Posts linked to a media item' }, async (uri, { id }) => {
|
||||
const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tool registration ──────────────────────────────────────────────
|
||||
|
||||
private registerReadTools(server: McpServer): void {
|
||||
server.registerTool('search_posts', {
|
||||
title: 'Search Posts',
|
||||
description: 'Search blog posts by query, category, tags, or date range.',
|
||||
inputSchema: {
|
||||
query: z.string().optional().describe('Full-text search query'),
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags'),
|
||||
year: z.number().optional().describe('Filter by year'),
|
||||
month: z.number().optional().describe('Filter by month (1-12)'),
|
||||
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
|
||||
offset: z.number().optional().describe('Pagination offset'),
|
||||
limit: z.number().optional().describe('Max results to return'),
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}, async (args) => {
|
||||
if (args.query) {
|
||||
const results = await this.deps.getPostEngine().searchPosts(args.query);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
||||
}
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (args.category) filter.categories = [args.category];
|
||||
if (args.tags) filter.tags = args.tags;
|
||||
if (args.year) filter.year = args.year;
|
||||
if (args.month) filter.month = args.month;
|
||||
if (args.status) filter.status = args.status;
|
||||
const results = await this.deps.getPostEngine().getPostsFiltered(filter);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
||||
});
|
||||
}
|
||||
|
||||
private registerProposalTools(server: McpServer): void {
|
||||
// ── draft_post ──
|
||||
registerAppTool(server, 'draft_post', {
|
||||
title: 'Draft Post',
|
||||
description: 'Create a new draft blog post for review before publishing.',
|
||||
inputSchema: {
|
||||
title: z.string().describe('Post title'),
|
||||
content: z.string().describe('Post content in Markdown'),
|
||||
excerpt: z.string().optional().describe('Short excerpt/summary'),
|
||||
tags: z.array(z.string()).optional().describe('Tags for the post'),
|
||||
categories: z.array(z.string()).optional().describe('Categories for the post'),
|
||||
author: z.string().optional().describe('Post author name'),
|
||||
},
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-post' } },
|
||||
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
|
||||
const post = await this.deps.getPostEngine().createPost({
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
excerpt: args.excerpt,
|
||||
tags: args.tags ?? [],
|
||||
categories: args.categories ?? [],
|
||||
author: args.author,
|
||||
status: 'draft',
|
||||
});
|
||||
const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record<string, unknown>).id });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── propose_script ──
|
||||
registerAppTool(server, 'propose_script', {
|
||||
title: 'Propose Script',
|
||||
description: 'Propose a new Python script (macro, utility, or transform) for review.',
|
||||
inputSchema: {
|
||||
title: z.string().describe('Script title'),
|
||||
kind: z.enum(['macro', 'utility', 'transform']).describe('Script type'),
|
||||
content: z.string().describe('Python source code'),
|
||||
entrypoint: z.string().optional().describe('Entry point function name'),
|
||||
},
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-script' } },
|
||||
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
|
||||
const proposalId = this.proposalStore.create('proposeScript', {
|
||||
title: args.title,
|
||||
kind: args.kind,
|
||||
content: args.content,
|
||||
entrypoint: args.entrypoint,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── propose_template ──
|
||||
registerAppTool(server, 'propose_template', {
|
||||
title: 'Propose Template',
|
||||
description: 'Propose a new Liquid template for review.',
|
||||
inputSchema: {
|
||||
title: z.string().describe('Template title'),
|
||||
kind: z.enum(['post', 'list', 'not-found', 'partial']).describe('Template type'),
|
||||
content: z.string().describe('Liquid template content'),
|
||||
},
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-template' } },
|
||||
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
|
||||
const proposalId = this.proposalStore.create('proposeTemplate', {
|
||||
title: args.title,
|
||||
kind: args.kind,
|
||||
content: args.content,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── propose_media_metadata ──
|
||||
registerAppTool(server, 'propose_media_metadata', {
|
||||
title: 'Propose Media Metadata',
|
||||
description: 'Propose metadata changes for a media item (alt text, caption, title, tags).',
|
||||
inputSchema: {
|
||||
mediaId: z.string().describe('ID of the media item to update'),
|
||||
alt: z.string().optional().describe('New alt text'),
|
||||
caption: z.string().optional().describe('New caption'),
|
||||
title: z.string().optional().describe('New title'),
|
||||
tags: z.array(z.string()).optional().describe('New tags'),
|
||||
},
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
|
||||
}, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => {
|
||||
const { mediaId, ...changes } = args;
|
||||
const current = await this.deps.getMediaEngine().getMedia(mediaId);
|
||||
const proposalId = this.proposalStore.create('proposeMediaMetadata', {
|
||||
mediaId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── propose_post_metadata ──
|
||||
registerAppTool(server, 'propose_post_metadata', {
|
||||
title: 'Propose Post Metadata',
|
||||
description: 'Propose metadata changes for a post (title, excerpt, tags, categories).',
|
||||
inputSchema: {
|
||||
postId: z.string().describe('ID of the post to update'),
|
||||
title: z.string().optional().describe('New title'),
|
||||
excerpt: z.string().optional().describe('New excerpt'),
|
||||
tags: z.array(z.string()).optional().describe('New tags'),
|
||||
categories: z.array(z.string()).optional().describe('New categories'),
|
||||
},
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
|
||||
}, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
|
||||
const { postId, ...changes } = args;
|
||||
const current = await this.deps.getPostEngine().getPost(postId);
|
||||
const proposalId = this.proposalStore.create('proposePostMetadata', {
|
||||
postId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── Register ui:// resources for review Views ──
|
||||
this.registerReviewResources(server);
|
||||
}
|
||||
|
||||
private registerReviewResources(server: McpServer): void {
|
||||
registerAppResource(server, 'Post Review', 'ui://bds/review-post', {
|
||||
description: 'Interactive review UI for draft posts',
|
||||
}, async () => ({
|
||||
contents: [{ uri: 'ui://bds/review-post', mimeType: RESOURCE_MIME_TYPE, text: reviewPostHtml() }],
|
||||
}));
|
||||
|
||||
registerAppResource(server, 'Script Review', 'ui://bds/review-script', {
|
||||
description: 'Interactive review UI for proposed scripts',
|
||||
}, async () => ({
|
||||
contents: [{ uri: 'ui://bds/review-script', mimeType: RESOURCE_MIME_TYPE, text: reviewScriptHtml() }],
|
||||
}));
|
||||
|
||||
registerAppResource(server, 'Template Review', 'ui://bds/review-template', {
|
||||
description: 'Interactive review UI for proposed templates',
|
||||
}, async () => ({
|
||||
contents: [{ uri: 'ui://bds/review-template', mimeType: RESOURCE_MIME_TYPE, text: reviewTemplateHtml() }],
|
||||
}));
|
||||
|
||||
registerAppResource(server, 'Metadata Review', 'ui://bds/review-metadata', {
|
||||
description: 'Interactive review UI for proposed metadata changes',
|
||||
}, async () => ({
|
||||
contents: [{ uri: 'ui://bds/review-metadata', mimeType: RESOURCE_MIME_TYPE, text: reviewMetadataHtml() }],
|
||||
}));
|
||||
}
|
||||
|
||||
private registerAcceptDiscardTools(server: McpServer): void {
|
||||
server.registerTool('accept_proposal', {
|
||||
title: 'Accept Proposal',
|
||||
description: 'Accept a pending proposal, committing the proposed change.',
|
||||
inputSchema: {
|
||||
proposalId: z.string().describe('ID of the proposal to accept'),
|
||||
},
|
||||
annotations: { idempotentHint: true },
|
||||
}, async (args) => {
|
||||
const result = await this.acceptProposal(args.proposalId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||
});
|
||||
|
||||
server.registerTool('discard_proposal', {
|
||||
title: 'Discard Proposal',
|
||||
description: 'Discard a pending proposal, rolling back any draft changes.',
|
||||
inputSchema: {
|
||||
proposalId: z.string().describe('ID of the proposal to discard'),
|
||||
},
|
||||
annotations: { idempotentHint: true },
|
||||
}, async (args) => {
|
||||
const result = await this.discardProposal(args.proposalId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt registration ────────────────────────────────────────────
|
||||
|
||||
private registerPrompts(server: McpServer): void {
|
||||
server.registerPrompt('draft-blog-post', {
|
||||
title: 'Draft Blog Post',
|
||||
description: 'Guides you through drafting a new blog post with proper structure and metadata.',
|
||||
argsSchema: {
|
||||
topic: z.string().optional().describe('Topic or title idea for the post'),
|
||||
category: z.string().optional().describe('Category to write for'),
|
||||
},
|
||||
}, async (args) => ({
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildDraftPostPrompt(args.topic, args.category),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
server.registerPrompt('improve-media-metadata', {
|
||||
title: 'Improve Media Metadata',
|
||||
description: 'Guides you through reviewing and improving media metadata (alt text, captions).',
|
||||
argsSchema: {
|
||||
scope: z.enum(['all', 'missing-alt', 'missing-caption']).optional().describe('Which media items to review'),
|
||||
},
|
||||
}, async (args) => ({
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildMediaMetadataPrompt(args.scope),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
server.registerPrompt('content-audit', {
|
||||
title: 'Content Audit',
|
||||
description: 'Guides you through auditing blog content for quality, SEO, and consistency.',
|
||||
argsSchema: {
|
||||
category: z.string().optional().describe('Category to audit (omit for all)'),
|
||||
},
|
||||
}, async (args) => ({
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildContentAuditPrompt(args.category),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt builders ─────────────────────────────────────────────────
|
||||
|
||||
function buildDraftPostPrompt(topic?: string, category?: string): string {
|
||||
const parts = [
|
||||
'You are helping draft a blog post for a Blogging Desktop Server instance.',
|
||||
'',
|
||||
'Steps:',
|
||||
'1. Read the existing content using the `bds://posts`, `bds://tags`, and `bds://categories` resources to understand the blog\'s style and topics.',
|
||||
'2. Draft a new post with a compelling title, well-structured Markdown content, appropriate tags and categories.',
|
||||
'3. Use the `draft_post` tool to create the draft for the user to review.',
|
||||
'',
|
||||
];
|
||||
if (topic) parts.push(`Suggested topic: ${topic}`);
|
||||
if (category) parts.push(`Target category: ${category}`);
|
||||
parts.push('', 'Ensure the post matches the existing blog style and quality standards.');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildMediaMetadataPrompt(scope?: string): string {
|
||||
const parts = [
|
||||
'You are helping improve media metadata in a Blogging Desktop Server instance.',
|
||||
'',
|
||||
'Steps:',
|
||||
'1. Read the `bds://media` resource to see all media items.',
|
||||
'2. Review each item\'s alt text, caption, and title.',
|
||||
];
|
||||
if (scope === 'missing-alt') {
|
||||
parts.push('3. Focus on items that are missing alt text.');
|
||||
} else if (scope === 'missing-caption') {
|
||||
parts.push('3. Focus on items that are missing captions.');
|
||||
} else {
|
||||
parts.push('3. Review all items for completeness and quality.');
|
||||
}
|
||||
parts.push(
|
||||
'4. For each item that needs improvement, use `propose_media_metadata` to suggest changes.',
|
||||
'',
|
||||
'Write descriptive, accessible alt text and informative captions.',
|
||||
);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildContentAuditPrompt(category?: string): string {
|
||||
const parts = [
|
||||
'You are helping audit blog content in a Blogging Desktop Server instance.',
|
||||
'',
|
||||
'Steps:',
|
||||
'1. Read the blog content using `bds://posts`, `bds://stats`, `bds://tags`, and `bds://categories` resources.',
|
||||
];
|
||||
if (category) {
|
||||
parts.push(`2. Focus your audit on posts in the "${category}" category.`);
|
||||
} else {
|
||||
parts.push('2. Review all posts across all categories.');
|
||||
}
|
||||
parts.push(
|
||||
'3. Check for:',
|
||||
' - Posts with missing or poor excerpts',
|
||||
' - Inconsistent tagging or categorization',
|
||||
' - Posts that could benefit from better titles',
|
||||
' - Orphaned posts with no backlinks or outlinks',
|
||||
'4. Use `propose_post_metadata` for any metadata improvements you recommend.',
|
||||
'',
|
||||
'Provide a summary of your findings and the proposals you created.',
|
||||
);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function parseBody(req: { on: (event: string, cb: (data: unknown) => void) => void }): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: unknown) => chunks.push(chunk as Buffer));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve(body ? JSON.parse(body) : undefined);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Singleton ───────────────────────────────────────────────────────
|
||||
|
||||
let mcpServerInstance: MCPServer | null = null;
|
||||
|
||||
export function getMCPServer(deps?: MCPServerDependencies): MCPServer {
|
||||
if (!mcpServerInstance) {
|
||||
if (!deps) {
|
||||
// Import singletons lazily to avoid circular deps
|
||||
const { getPostEngine } = require('./PostEngine');
|
||||
const { getMediaEngine } = require('./MediaEngine');
|
||||
const { getScriptEngine } = require('./ScriptEngine');
|
||||
const { getTemplateEngine } = require('./TemplateEngine');
|
||||
const { getMetaEngine } = require('./MetaEngine');
|
||||
const { getPostMediaEngine } = require('./PostMediaEngine');
|
||||
const { getTagEngine } = require('./TagEngine');
|
||||
deps = {
|
||||
getPostEngine, getMediaEngine, getScriptEngine,
|
||||
getTemplateEngine, getMetaEngine, getPostMediaEngine, getTagEngine,
|
||||
};
|
||||
}
|
||||
mcpServerInstance = new MCPServer(deps);
|
||||
}
|
||||
return mcpServerInstance;
|
||||
}
|
||||
|
||||
export function resetMCPServer(): void {
|
||||
mcpServerInstance = null;
|
||||
}
|
||||
68
src/main/engine/ProposalStore.ts
Normal file
68
src/main/engine/ProposalStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type ProposalType =
|
||||
| 'draftPost'
|
||||
| 'proposeScript'
|
||||
| 'proposeTemplate'
|
||||
| 'proposeMediaMetadata'
|
||||
| 'proposePostMetadata';
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
type: ProposalType;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export class ProposalStore {
|
||||
private readonly proposals = new Map<string, Proposal>();
|
||||
private readonly ttlMs: number;
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs);
|
||||
}
|
||||
|
||||
create(type: ProposalType, data: Record<string, unknown>): string {
|
||||
const id = randomUUID();
|
||||
this.proposals.set(id, {
|
||||
id,
|
||||
type,
|
||||
data,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
get(id: string): Proposal | undefined {
|
||||
return this.proposals.get(id);
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
this.proposals.delete(id);
|
||||
}
|
||||
|
||||
getAll(): Proposal[] {
|
||||
return Array.from(this.proposals.values());
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, proposal] of this.proposals) {
|
||||
if (now - proposal.createdAt > this.ttlMs) {
|
||||
this.proposals.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.proposals.clear();
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/main/engine/mcp-views.ts
Normal file
258
src/main/engine/mcp-views.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* MCP App Review Views — inline HTML strings for review UIs.
|
||||
*
|
||||
* Each function returns a self-contained HTML page that uses the
|
||||
* `App` class from `@modelcontextprotocol/ext-apps` (loaded via
|
||||
* the `app-with-deps` bundle that includes its own dependencies).
|
||||
*
|
||||
* These Views are served as `ui://` resources and rendered inline
|
||||
* 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; }
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
`;
|
||||
}
|
||||
|
||||
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 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 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 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>`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getMetaEngine } from './engine/MetaEngine';
|
||||
import { getTemplateEngine } from './engine/TemplateEngine';
|
||||
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
||||
import { PreviewServer } from './engine/PreviewServer';
|
||||
import { getMCPServer } from './engine/MCPServer';
|
||||
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
||||
import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
|
||||
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
|
||||
@@ -24,6 +25,7 @@ let blogmarkQueueProcessing = false;
|
||||
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
||||
let rendererReady = false;
|
||||
const PREVIEW_SERVER_PORT = 4123;
|
||||
const MCP_SERVER_PORT = 4124;
|
||||
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
|
||||
const BLOGMARK_PROTOCOL = 'bds';
|
||||
const BLOGMARK_NEW_POST_PREFIX = `${BLOGMARK_PROTOCOL}://new-post`;
|
||||
@@ -864,6 +866,12 @@ app.whenReady().then(async () => {
|
||||
} catch (error) {
|
||||
console.error('Failed to start preview server on app startup:', error);
|
||||
}
|
||||
try {
|
||||
const mcpServer = getMCPServer();
|
||||
await mcpServer.start(MCP_SERVER_PORT);
|
||||
} catch (error) {
|
||||
console.error('Failed to start MCP server on app startup:', error);
|
||||
}
|
||||
createWindow();
|
||||
|
||||
await activeProjectContextReady;
|
||||
@@ -897,6 +905,13 @@ app.on('before-quit', async () => {
|
||||
previewServer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const mcpServer = getMCPServer();
|
||||
await mcpServer.cleanup();
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup MCP server:', error);
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user