feat: mcp server implementation round 2

This commit is contained in:
2026-02-28 09:31:58 +01:00
parent 690b90abcf
commit 9efe007791
5 changed files with 674 additions and 17 deletions

View File

@@ -37,6 +37,7 @@ interface MediaEngineContract {
getAllMedia: () => Promise<Array<Record<string, unknown>>>; getAllMedia: () => Promise<Array<Record<string, unknown>>>;
getMedia: (id: string) => Promise<Record<string, unknown> | null>; getMedia: (id: string) => Promise<Record<string, unknown> | null>;
updateMedia: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>; updateMedia: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise<string | null>;
} }
interface ScriptEngineContract { interface ScriptEngineContract {
@@ -309,6 +310,22 @@ export class MCPServer {
const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string); const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string);
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
}); });
server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => {
const mediaId = id as string;
const media = await this.deps.getMediaEngine().getMedia(mediaId);
if (!media || !(media as Record<string, unknown>).mimeType || !String((media as Record<string, unknown>).mimeType).startsWith('image/')) {
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] };
}
const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium');
if (!dataUrl) {
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Thumbnail not available' }] };
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
return {
contents: [{ uri: uri.href, mimeType: 'image/webp', blob: base64Data }],
};
});
} }
// ── Tool registration ────────────────────────────────────────────── // ── Tool registration ──────────────────────────────────────────────
@@ -329,17 +346,34 @@ export class MCPServer {
}, },
annotations: { readOnlyHint: true, openWorldHint: false }, annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => { }, async (args) => {
if (args.query) { const hasFilters = args.category || args.tags || args.year || args.month || args.status;
if (args.query && !hasFilters) {
// Pure text search — use FTS
const results = await this.deps.getPostEngine().searchPosts(args.query); const results = await this.deps.getPostEngine().searchPosts(args.query);
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
} }
// Filter-based query (optionally narrowed by text search)
const filter: Record<string, unknown> = {}; const filter: Record<string, unknown> = {};
if (args.category) filter.categories = [args.category]; if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags; if (args.tags) filter.tags = args.tags;
if (args.year) filter.year = args.year; if (args.year) filter.year = args.year;
if (args.month) filter.month = args.month; if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status; if (args.status) filter.status = args.status;
const results = await this.deps.getPostEngine().getPostsFiltered(filter); let results = await this.deps.getPostEngine().getPostsFiltered(filter);
// Client-side text filter when query is combined with structured filters
if (args.query) {
const q = args.query.toLowerCase();
results = results.filter((p: Record<string, unknown>) => {
const title = String(p.title ?? '').toLowerCase();
const content = String(p.content ?? '').toLowerCase();
const excerpt = String(p.excerpt ?? '').toLowerCase();
return title.includes(q) || content.includes(q) || excerpt.includes(q);
});
}
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
}); });
} }
@@ -357,6 +391,7 @@ export class MCPServer {
categories: z.array(z.string()).optional().describe('Categories for the post'), categories: z.array(z.string()).optional().describe('Categories for the post'),
author: z.string().optional().describe('Post author name'), author: z.string().optional().describe('Post author name'),
}, },
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-post' } }, _meta: { ui: { resourceUri: 'ui://bds/review-post' } },
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => { }, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
const post = await this.deps.getPostEngine().createPost({ const post = await this.deps.getPostEngine().createPost({
@@ -384,6 +419,7 @@ export class MCPServer {
content: z.string().describe('Python source code'), content: z.string().describe('Python source code'),
entrypoint: z.string().optional().describe('Entry point function name'), entrypoint: z.string().optional().describe('Entry point function name'),
}, },
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-script' } }, _meta: { ui: { resourceUri: 'ui://bds/review-script' } },
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => { }, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
const proposalId = this.proposalStore.create('proposeScript', { const proposalId = this.proposalStore.create('proposeScript', {
@@ -406,6 +442,7 @@ export class MCPServer {
kind: z.enum(['post', 'list', 'not-found', 'partial']).describe('Template type'), kind: z.enum(['post', 'list', 'not-found', 'partial']).describe('Template type'),
content: z.string().describe('Liquid template content'), content: z.string().describe('Liquid template content'),
}, },
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-template' } }, _meta: { ui: { resourceUri: 'ui://bds/review-template' } },
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => { }, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
const proposalId = this.proposalStore.create('proposeTemplate', { const proposalId = this.proposalStore.create('proposeTemplate', {
@@ -429,6 +466,7 @@ export class MCPServer {
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
tags: z.array(z.string()).optional().describe('New tags'), tags: z.array(z.string()).optional().describe('New tags'),
}, },
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
}, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => { }, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => {
const { mediaId, ...changes } = args; const { mediaId, ...changes } = args;
@@ -453,6 +491,7 @@ export class MCPServer {
tags: z.array(z.string()).optional().describe('New tags'), tags: z.array(z.string()).optional().describe('New tags'),
categories: z.array(z.string()).optional().describe('New categories'), categories: z.array(z.string()).optional().describe('New categories'),
}, },
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
}, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { }, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
const { postId, ...changes } = args; const { postId, ...changes } = args;
@@ -503,7 +542,7 @@ export class MCPServer {
inputSchema: { inputSchema: {
proposalId: z.string().describe('ID of the proposal to accept'), proposalId: z.string().describe('ID of the proposal to accept'),
}, },
annotations: { idempotentHint: true }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
}, async (args) => { }, async (args) => {
const result = await this.acceptProposal(args.proposalId); const result = await this.acceptProposal(args.proposalId);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
@@ -515,7 +554,7 @@ export class MCPServer {
inputSchema: { inputSchema: {
proposalId: z.string().describe('ID of the proposal to discard'), proposalId: z.string().describe('ID of the proposal to discard'),
}, },
annotations: { idempotentHint: true }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
}, async (args) => { }, async (args) => {
const result = await this.discardProposal(args.proposalId); const result = await this.discardProposal(args.proposalId);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
@@ -667,18 +706,7 @@ let mcpServerInstance: MCPServer | null = null;
export function getMCPServer(deps?: MCPServerDependencies): MCPServer { export function getMCPServer(deps?: MCPServerDependencies): MCPServer {
if (!mcpServerInstance) { if (!mcpServerInstance) {
if (!deps) { if (!deps) {
// Import singletons lazily to avoid circular deps throw new Error('MCPServer dependencies must be provided on first call to getMCPServer()');
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); mcpServerInstance = new MCPServer(deps);
} }

View File

@@ -9,6 +9,9 @@ import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine'; import { getPostEngine } from './engine/PostEngine';
import { getMetaEngine } from './engine/MetaEngine'; import { getMetaEngine } from './engine/MetaEngine';
import { getTemplateEngine } from './engine/TemplateEngine'; import { getTemplateEngine } from './engine/TemplateEngine';
import { getScriptEngine } from './engine/ScriptEngine';
import { getPostMediaEngine } from './engine/PostMediaEngine';
import { getTagEngine } from './engine/TagEngine';
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService'; import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
import { PreviewServer } from './engine/PreviewServer'; import { PreviewServer } from './engine/PreviewServer';
import { getMCPServer } from './engine/MCPServer'; import { getMCPServer } from './engine/MCPServer';
@@ -867,7 +870,15 @@ app.whenReady().then(async () => {
console.error('Failed to start preview server on app startup:', error); console.error('Failed to start preview server on app startup:', error);
} }
try { try {
const mcpServer = getMCPServer(); const mcpServer = getMCPServer({
getPostEngine: () => getPostEngine() as never,
getMediaEngine: () => getMediaEngine() as never,
getScriptEngine: () => getScriptEngine() as never,
getTemplateEngine: () => getTemplateEngine() as never,
getMetaEngine: () => getMetaEngine() as never,
getPostMediaEngine: () => getPostMediaEngine() as never,
getTagEngine: () => getTagEngine() as never,
});
await mcpServer.start(MCP_SERVER_PORT); await mcpServer.start(MCP_SERVER_PORT);
} catch (error) { } catch (error) {
console.error('Failed to start MCP server on app startup:', error); console.error('Failed to start MCP server on app startup:', error);

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer';
/**
* Integration tests for MCPServer HTTP transport.
* These start the actual HTTP server and send MCP protocol requests.
*/
function createMockDeps(): MCPServerDependencies {
return {
getPostEngine: () => ({
getAllPosts: vi.fn().mockResolvedValue({ items: [{ id: 'p1', title: 'Test Post' }], hasMore: false, total: 1 }),
getPost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Test Post', content: '# Hello', slug: 'test' }),
searchPosts: vi.fn().mockResolvedValue([{ id: 'p1', title: 'Test Post', slug: 'test' }]),
createPost: vi.fn().mockResolvedValue({ id: 'draft-1', title: 'Draft', status: 'draft' }),
updatePost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Updated' }),
publishPost: vi.fn().mockResolvedValue({ id: 'draft-1', status: 'published' }),
deletePost: vi.fn().mockResolvedValue(true),
getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'js', count: 5 }]),
getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'tech', count: 3 }]),
getBlogStats: vi.fn().mockResolvedValue({ totalPosts: 10 }),
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
}),
getMediaEngine: () => ({
getAllMedia: vi.fn().mockResolvedValue([]),
getMedia: vi.fn().mockResolvedValue(null),
updateMedia: vi.fn().mockResolvedValue(null),
getThumbnailDataUrl: vi.fn().mockResolvedValue(null),
}),
getScriptEngine: () => ({
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
}),
getTemplateEngine: () => ({
createTemplate: vi.fn().mockResolvedValue({ id: 't1' }),
}),
getMetaEngine: () => ({
getProjectMetadata: vi.fn().mockResolvedValue(null),
}),
getPostMediaEngine: () => ({
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
}),
getTagEngine: () => ({
getTagsWithCounts: vi.fn().mockResolvedValue([]),
}),
};
}
async function sendMcpRequest(port: number, method: string, params?: unknown, id = 1): Promise<unknown> {
const body = JSON.stringify({
jsonrpc: '2.0',
id,
method,
params: params ?? {},
});
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body,
});
const text = await response.text();
const contentType = response.headers.get('content-type') ?? '';
// SSE format: "event: message\ndata: {json}\n\n"
if (contentType.includes('text/event-stream')) {
const dataLine = text.split('\n').find(l => l.startsWith('data: '));
if (dataLine) {
return JSON.parse(dataLine.slice(6));
}
}
return JSON.parse(text);
}
async function initializeSession(port: number): Promise<unknown> {
return sendMcpRequest(port, 'initialize', {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
});
}
describe('MCPServer integration', () => {
let server: MCPServer;
beforeEach(() => {
server = new MCPServer(createMockDeps());
});
afterEach(async () => {
await server.cleanup();
});
it('starts on a random port and responds to initialize', async () => {
const port = await server.start(0);
expect(port).toBeGreaterThan(0);
const result = await initializeSession(port) as { result?: { serverInfo?: { name: string } } };
expect(result.result?.serverInfo?.name).toBe('Blogging Desktop Server');
});
it('returns CORS headers', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'OPTIONS',
});
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
});
it('lists tools via tools/list after initialize', async () => {
const port = await server.start(0);
// MCP requires single request per connection in stateless mode — each request creates a new session
// Send initialize + tools/list in same session is not possible in stateless mode
// Instead, we can verify the server responds to a standalone initialize
const initResult = await initializeSession(port) as { result?: { capabilities?: { tools?: unknown } } };
expect(initResult.result?.capabilities?.tools).toBeDefined();
});
it('getPort returns the listening port', async () => {
expect(server.getPort()).toBeNull();
const port = await server.start(0);
expect(server.getPort()).toBe(port);
await server.stop();
expect(server.getPort()).toBeNull();
});
it('handles multiple sequential requests', async () => {
const port = await server.start(0);
const r1 = await initializeSession(port) as { result?: unknown };
const r2 = await initializeSession(port) as { result?: unknown };
expect(r1.result).toBeDefined();
expect(r2.result).toBeDefined();
});
it('returns 500 for malformed JSON', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body: '{invalid json',
});
// The server should handle this gracefully
expect(response.status).toBeGreaterThanOrEqual(400);
});
it('stop is idempotent', async () => {
await server.start(0);
await server.stop();
await expect(server.stop()).resolves.toBeUndefined();
});
it('cleanup stops server and clears proposals', async () => {
server.proposalStore.create('draftPost', { postId: 'p1' });
await server.start(0);
await server.cleanup();
expect(server.proposalStore.getAll()).toHaveLength(0);
expect(server.getPort()).toBeNull();
});
});

View File

@@ -53,6 +53,7 @@ function createMockMediaEngine() {
getAllMedia: vi.fn().mockResolvedValue([]), getAllMedia: vi.fn().mockResolvedValue([]),
getMedia: vi.fn().mockResolvedValue(null), getMedia: vi.fn().mockResolvedValue(null),
updateMedia: vi.fn().mockResolvedValue(null), updateMedia: vi.fn().mockResolvedValue(null),
getThumbnailDataUrl: vi.fn().mockResolvedValue(null),
}; };
} }
@@ -131,6 +132,7 @@ describe('MCPServer', () => {
let mockMediaEngine: ReturnType<typeof createMockMediaEngine>; let mockMediaEngine: ReturnType<typeof createMockMediaEngine>;
let mockScriptEngine: ReturnType<typeof createMockScriptEngine>; let mockScriptEngine: ReturnType<typeof createMockScriptEngine>;
let mockTemplateEngine: ReturnType<typeof createMockTemplateEngine>; let mockTemplateEngine: ReturnType<typeof createMockTemplateEngine>;
let mockPostMediaEngine: ReturnType<typeof createMockPostMediaEngine>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -140,6 +142,7 @@ describe('MCPServer', () => {
mockMediaEngine = mocks.mockMediaEngine; mockMediaEngine = mocks.mockMediaEngine;
mockScriptEngine = mocks.mockScriptEngine; mockScriptEngine = mocks.mockScriptEngine;
mockTemplateEngine = mocks.mockTemplateEngine; mockTemplateEngine = mocks.mockTemplateEngine;
mockPostMediaEngine = mocks.mockPostMediaEngine;
server = new MCPServer(deps); server = new MCPServer(deps);
}); });
@@ -261,6 +264,11 @@ describe('MCPServer', () => {
const mcpServer = server.createMcpServer(); const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-posts')).toBe(true); expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-posts')).toBe(true);
}); });
it('registers media-image resource template', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true);
});
}); });
describe('registered prompts', () => { describe('registered prompts', () => {
@@ -387,4 +395,275 @@ describe('MCPServer', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
}); });
// ── Tool annotations ────────────────────────────────────────────────
describe('tool annotations', () => {
function getToolAnnotations(toolName: string): Record<string, unknown> | undefined {
const mcpServer = server.createMcpServer();
const tool = (mcpServer as Record<string, Record<string, { annotations?: Record<string, unknown> }>>)._registeredTools[toolName];
return tool?.annotations;
}
it('search_posts has readOnlyHint true', () => {
const annotations = getToolAnnotations('search_posts');
expect(annotations).toEqual({ readOnlyHint: true, openWorldHint: false });
});
it('draft_post has readOnlyHint false, destructiveHint false', () => {
const annotations = getToolAnnotations('draft_post');
expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
});
it('propose_script has readOnlyHint false, destructiveHint false', () => {
const annotations = getToolAnnotations('propose_script');
expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
});
it('propose_template has readOnlyHint false, destructiveHint false', () => {
const annotations = getToolAnnotations('propose_template');
expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
});
it('propose_media_metadata has readOnlyHint false, destructiveHint false', () => {
const annotations = getToolAnnotations('propose_media_metadata');
expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
});
it('propose_post_metadata has readOnlyHint false, destructiveHint false', () => {
const annotations = getToolAnnotations('propose_post_metadata');
expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
});
it('accept_proposal has readOnlyHint false, destructiveHint false, idempotentHint true', () => {
const annotations = getToolAnnotations('accept_proposal');
expect(annotations).toEqual({ readOnlyHint: false, destructiveHint: false, idempotentHint: true });
});
it('discard_proposal has readOnlyHint false, destructiveHint true, idempotentHint true', () => {
const annotations = getToolAnnotations('discard_proposal');
expect(annotations).toEqual({ readOnlyHint: false, destructiveHint: true, idempotentHint: true });
});
});
// ── Resource handler behavior ───────────────────────────────────────
describe('resource handlers', () => {
function getResource(mcpServer: unknown, uri: string) {
return (mcpServer as Record<string, Record<string, { readCallback: (...args: unknown[]) => Promise<unknown> }>>)._registeredResources[uri];
}
function getResourceTemplate(mcpServer: unknown, name: string) {
return (mcpServer as Record<string, Record<string, { readCallback: (...args: unknown[]) => Promise<unknown> }>>)._registeredResourceTemplates[name];
}
it('bds://posts calls getAllPosts and returns JSON', async () => {
const postsData = { items: [{ id: 'p1', title: 'Hello' }], hasMore: false, total: 1 };
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://posts');
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
expect(mockPostEngine.getAllPosts).toHaveBeenCalled();
expect(JSON.parse(result.contents[0].text)).toEqual(postsData);
});
it('bds://stats calls getBlogStats and returns JSON', async () => {
const stats = { totalPosts: 42 };
mockPostEngine.getBlogStats.mockResolvedValue(stats);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://stats');
const result = await resource.readCallback(new URL('bds://stats'), {}) as { contents: Array<{ text: string }> };
expect(JSON.parse(result.contents[0].text)).toEqual(stats);
});
it('bds://posts/{id} calls getPost with correct id', async () => {
const post = { id: 'post-1', title: 'Test' };
mockPostEngine.getPost.mockResolvedValue(post);
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'post');
const result = await tpl.readCallback(new URL('bds://posts/post-1'), { id: 'post-1' }, {}) as { contents: Array<{ text: string }> };
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
expect(JSON.parse(result.contents[0].text)).toEqual(post);
});
it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => {
const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }];
mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia);
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'post-media');
const result = await tpl.readCallback(new URL('bds://posts/p1/media'), { id: 'p1' }, {}) as { contents: Array<{ text: string }> };
expect(mockPostMediaEngine.getLinkedMediaDataForPost).toHaveBeenCalledWith('p1');
expect(JSON.parse(result.contents[0].text)).toEqual(linkedMedia);
});
it('bds://media/{id}/image returns thumbnail blob for images', async () => {
mockMediaEngine.getMedia.mockResolvedValue({ id: 'img-1', mimeType: 'image/jpeg', filename: 'photo.jpg' });
mockMediaEngine.getThumbnailDataUrl.mockResolvedValue('data:image/webp;base64,AAAA');
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'media-image');
const result = await tpl.readCallback(new URL('bds://media/img-1/image'), { id: 'img-1' }, {}) as { contents: Array<{ mimeType: string; blob: string }> };
expect(mockMediaEngine.getThumbnailDataUrl).toHaveBeenCalledWith('img-1', 'medium');
expect(result.contents[0].mimeType).toBe('image/webp');
expect(result.contents[0].blob).toBe('AAAA');
});
it('bds://media/{id}/image returns text error for non-images', async () => {
mockMediaEngine.getMedia.mockResolvedValue({ id: 'doc-1', mimeType: 'application/pdf', filename: 'doc.pdf' });
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'media-image');
const result = await tpl.readCallback(new URL('bds://media/doc-1/image'), { id: 'doc-1' }, {}) as { contents: Array<{ mimeType: string; text: string }> };
expect(result.contents[0].mimeType).toBe('text/plain');
expect(result.contents[0].text).toContain('Not an image');
});
it('bds://media/{id}/image returns text error when thumbnail unavailable', async () => {
mockMediaEngine.getMedia.mockResolvedValue({ id: 'img-2', mimeType: 'image/png', filename: 'pic.png' });
mockMediaEngine.getThumbnailDataUrl.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'media-image');
const result = await tpl.readCallback(new URL('bds://media/img-2/image'), { id: 'img-2' }, {}) as { contents: Array<{ mimeType: string; text: string }> };
expect(result.contents[0].text).toContain('Thumbnail not available');
});
});
// ── Tool handler behavior ──────────────────────────────────────────
describe('tool handlers', () => {
function getTool(mcpServer: unknown, name: string) {
return (mcpServer as Record<string, Record<string, { handler: (...args: unknown[]) => Promise<unknown> }>>)._registeredTools[name];
}
it('search_posts with query only calls searchPosts', async () => {
const searchResults = [{ id: 'p1', title: 'Found', slug: 'found' }];
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test');
expect(JSON.parse(result.content[0].text)).toEqual(searchResults);
});
it('search_posts with filters only calls getPostsFiltered', async () => {
const filtered = [{ id: 'p2', title: 'Filtered' }];
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' });
expect(JSON.parse(result.content[0].text)).toEqual(filtered);
});
it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => {
const allFiltered = [
{ id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' },
{ id: 'p2', title: 'Python Guide', content: 'Learn Python', excerpt: '' },
];
mockPostEngine.getPostsFiltered.mockResolvedValue(allFiltered);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalled();
expect(mockPostEngine.searchPosts).not.toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(1);
expect(parsed[0].title).toBe('TypeScript Guide');
});
it('draft_post creates a draft and stores proposal', async () => {
const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' };
mockPostEngine.createPost.mockResolvedValue(createdPost);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'draft_post');
const result = await tool.handler({ title: 'Draft Title', content: '# Hello' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.createPost).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft Title', content: '# Hello', status: 'draft' }));
const parsed = JSON.parse(result.content[0].text);
expect(parsed.proposalId).toBeTruthy();
expect(parsed.post).toEqual(createdPost);
// Verify proposal is in the store
const proposal = server.proposalStore.get(parsed.proposalId);
expect(proposal).toBeDefined();
expect(proposal!.type).toBe('draftPost');
expect(proposal!.data.postId).toBe('new-post');
});
it('propose_script stores proposal in ProposalStore', async () => {
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_script');
const result = await tool.handler({ title: 'My Script', kind: 'macro', content: 'print("hi")' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.proposalId).toBeTruthy();
const proposal = server.proposalStore.get(parsed.proposalId);
expect(proposal).toBeDefined();
expect(proposal!.type).toBe('proposeScript');
expect(proposal!.data.content).toBe('print("hi")');
});
it('propose_media_metadata loads current media and stores proposal', async () => {
const currentMedia = { id: 'img-1', alt: 'Old alt', title: 'Old title' };
mockMediaEngine.getMedia.mockResolvedValue(currentMedia);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_media_metadata');
const result = await tool.handler({ mediaId: 'img-1', alt: 'New alt' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.current).toEqual(currentMedia);
expect(parsed.proposed).toEqual({ alt: 'New alt' });
const proposal = server.proposalStore.get(parsed.proposalId);
expect(proposal!.type).toBe('proposeMediaMetadata');
expect(proposal!.data.mediaId).toBe('img-1');
});
it('propose_post_metadata loads current post and stores proposal', async () => {
const currentPost = { id: 'post-1', title: 'Old Title', excerpt: 'Old excerpt' };
mockPostEngine.getPost.mockResolvedValue(currentPost);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_post_metadata');
const result = await tool.handler({ postId: 'post-1', title: 'New Title' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.current).toEqual(currentPost);
expect(parsed.proposed).toEqual({ title: 'New Title' });
const proposal = server.proposalStore.get(parsed.proposalId);
expect(proposal!.type).toBe('proposePostMetadata');
});
});
// ── Prompt handler behavior ────────────────────────────────────────
describe('prompt handlers', () => {
function getPrompt(mcpServer: unknown, name: string) {
return (mcpServer as Record<string, Record<string, { callback: (...args: unknown[]) => Promise<unknown> }>>)._registeredPrompts[name];
}
it('draft-blog-post returns messages with topic', async () => {
const mcpServer = server.createMcpServer();
const prompt = getPrompt(mcpServer, 'draft-blog-post');
const result = await prompt.callback({ topic: 'AI Safety' }, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> };
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
expect(result.messages[0].content.text).toContain('AI Safety');
expect(result.messages[0].content.text).toContain('draft_post');
});
it('improve-media-metadata returns messages with scope', async () => {
const mcpServer = server.createMcpServer();
const prompt = getPrompt(mcpServer, 'improve-media-metadata');
const result = await prompt.callback({ scope: 'missing-alt' }, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> };
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content.text).toContain('missing alt text');
});
it('content-audit returns messages with category', async () => {
const mcpServer = server.createMcpServer();
const prompt = getPrompt(mcpServer, 'content-audit');
const result = await prompt.callback({ category: 'tech' }, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> };
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content.text).toContain('tech');
});
it('content-audit without category reviews all posts', async () => {
const mcpServer = server.createMcpServer();
const prompt = getPrompt(mcpServer, 'content-audit');
const result = await prompt.callback({}, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> };
expect(result.messages[0].content.text).toContain('all posts');
});
});
}); });

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import {
reviewPostHtml,
reviewScriptHtml,
reviewTemplateHtml,
reviewMetadataHtml,
} from '../../src/main/engine/mcp-views';
describe('mcp-views', () => {
describe('reviewPostHtml', () => {
it('returns valid HTML document', () => {
const html = reviewPostHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
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();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
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();
expect(html).toContain('Review Post');
expect(html).toContain('Publish');
expect(html).toContain('badge-draft');
expect(html).toContain('word-count');
});
it('renders tool result data via ontoolresult handler', () => {
const html = reviewPostHtml();
expect(html).toContain('app.ontoolresult');
expect(html).toContain('renderReview');
});
it('uses XSS-safe escaping function', () => {
const html = reviewPostHtml();
expect(html).toContain('function esc(');
expect(html).toContain('document.createElement("div")');
});
});
describe('reviewScriptHtml', () => {
it('returns valid HTML document', () => {
const html = reviewScriptHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewScriptHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
});
it('contains accept and discard buttons', () => {
const html = reviewScriptHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains script-specific UI elements', () => {
const html = reviewScriptHtml();
expect(html).toContain('Review Script');
expect(html).toContain('Create Script');
expect(html).toContain('Python Code');
});
});
describe('reviewTemplateHtml', () => {
it('returns valid HTML document', () => {
const html = reviewTemplateHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewTemplateHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
});
it('contains accept and discard buttons', () => {
const html = reviewTemplateHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains template-specific UI elements', () => {
const html = reviewTemplateHtml();
expect(html).toContain('Review Template');
expect(html).toContain('Create Template');
expect(html).toContain('Liquid Template');
});
});
describe('reviewMetadataHtml', () => {
it('returns valid HTML document', () => {
const html = reviewMetadataHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('contains App import from ext-apps', () => {
const html = reviewMetadataHtml();
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
});
it('contains accept and discard buttons', () => {
const html = reviewMetadataHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains metadata-diff UI elements', () => {
const html = reviewMetadataHtml();
expect(html).toContain('Metadata Changes');
expect(html).toContain('Apply Changes');
expect(html).toContain('diff-table');
expect(html).toContain('Current');
expect(html).toContain('Proposed');
});
it('contains diff formatting function', () => {
const html = reviewMetadataHtml();
expect(html).toContain('function fmt(');
expect(html).toContain('diff-old');
expect(html).toContain('diff-new');
});
});
describe('shared behavior', () => {
const allViews = [
{ 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 }) => {
const html = fn();
expect(html).toContain('app.connect()');
});
it.each(allViews)('$name has a status display element', ({ fn }) => {
const html = fn();
expect(html).toContain('id="status"');
expect(html).toContain('showStatus');
});
it.each(allViews)('$name disables buttons during action', ({ fn }) => {
const html = fn();
expect(html).toContain('setButtonsDisabled(true)');
});
it.each(allViews)('$name uses module script type', ({ fn }) => {
const html = fn();
expect(html).toContain('type="module"');
});
});
});