diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index ef90617..f975c8f 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -37,6 +37,7 @@ interface MediaEngineContract { getAllMedia: () => Promise>>; getMedia: (id: string) => Promise | null>; updateMedia: (id: string, data: Record) => Promise | null>; + getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise; } interface ScriptEngineContract { @@ -309,6 +310,22 @@ export class MCPServer { const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string); 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).mimeType || !String((media as Record).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 ────────────────────────────────────────────── @@ -329,17 +346,34 @@ export class MCPServer { }, annotations: { readOnlyHint: true, openWorldHint: false }, }, 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); return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; } + + // Filter-based query (optionally narrowed by text search) const filter: Record = {}; 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); + 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) => { + 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) }] }; }); } @@ -357,6 +391,7 @@ export class MCPServer { categories: z.array(z.string()).optional().describe('Categories for the post'), author: z.string().optional().describe('Post author name'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _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({ @@ -384,6 +419,7 @@ export class MCPServer { content: z.string().describe('Python source code'), entrypoint: z.string().optional().describe('Entry point function name'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _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', { @@ -406,6 +442,7 @@ export class MCPServer { kind: z.enum(['post', 'list', 'not-found', 'partial']).describe('Template type'), content: z.string().describe('Liquid template content'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _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', { @@ -429,6 +466,7 @@ export class MCPServer { title: z.string().optional().describe('New title'), tags: z.array(z.string()).optional().describe('New tags'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, }, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => { const { mediaId, ...changes } = args; @@ -453,6 +491,7 @@ export class MCPServer { tags: z.array(z.string()).optional().describe('New tags'), categories: z.array(z.string()).optional().describe('New categories'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, }, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { const { postId, ...changes } = args; @@ -503,7 +542,7 @@ export class MCPServer { inputSchema: { proposalId: z.string().describe('ID of the proposal to accept'), }, - annotations: { idempotentHint: true }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, }, async (args) => { const result = await this.acceptProposal(args.proposalId); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; @@ -515,7 +554,7 @@ export class MCPServer { inputSchema: { proposalId: z.string().describe('ID of the proposal to discard'), }, - annotations: { idempotentHint: true }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, async (args) => { const result = await this.discardProposal(args.proposalId); 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 { 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, - }; + throw new Error('MCPServer dependencies must be provided on first call to getMCPServer()'); } mcpServerInstance = new MCPServer(deps); } diff --git a/src/main/main.ts b/src/main/main.ts index f263c49..28ec7a1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -9,6 +9,9 @@ import { getMediaEngine } from './engine/MediaEngine'; import { getPostEngine } from './engine/PostEngine'; import { getMetaEngine } from './engine/MetaEngine'; 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 { PreviewServer } from './engine/PreviewServer'; import { getMCPServer } from './engine/MCPServer'; @@ -867,7 +870,15 @@ app.whenReady().then(async () => { console.error('Failed to start preview server on app startup:', error); } 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); } catch (error) { console.error('Failed to start MCP server on app startup:', error); diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts new file mode 100644 index 0000000..351e41c --- /dev/null +++ b/tests/engine/MCPServer.integration.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index d4fe865..1978eb1 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -53,6 +53,7 @@ function createMockMediaEngine() { getAllMedia: vi.fn().mockResolvedValue([]), getMedia: vi.fn().mockResolvedValue(null), updateMedia: vi.fn().mockResolvedValue(null), + getThumbnailDataUrl: vi.fn().mockResolvedValue(null), }; } @@ -131,6 +132,7 @@ describe('MCPServer', () => { let mockMediaEngine: ReturnType; let mockScriptEngine: ReturnType; let mockTemplateEngine: ReturnType; + let mockPostMediaEngine: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -140,6 +142,7 @@ describe('MCPServer', () => { mockMediaEngine = mocks.mockMediaEngine; mockScriptEngine = mocks.mockScriptEngine; mockTemplateEngine = mocks.mockTemplateEngine; + mockPostMediaEngine = mocks.mockPostMediaEngine; server = new MCPServer(deps); }); @@ -261,6 +264,11 @@ describe('MCPServer', () => { const mcpServer = server.createMcpServer(); 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', () => { @@ -387,4 +395,275 @@ describe('MCPServer', () => { expect(result.success).toBe(false); }); }); + + // ── Tool annotations ──────────────────────────────────────────────── + + describe('tool annotations', () => { + function getToolAnnotations(toolName: string): Record | undefined { + const mcpServer = server.createMcpServer(); + const tool = (mcpServer as Record }>>)._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 Promise }>>)._registeredResources[uri]; + } + + function getResourceTemplate(mcpServer: unknown, name: string) { + return (mcpServer as Record Promise }>>)._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 Promise }>>)._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 Promise }>>)._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'); + }); + }); }); diff --git a/tests/engine/mcp-views.test.ts b/tests/engine/mcp-views.test.ts new file mode 100644 index 0000000..4e72b42 --- /dev/null +++ b/tests/engine/mcp-views.test.ts @@ -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(''); + expect(html).toContain(''); + }); + + 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(''); + expect(html).toContain(''); + }); + + 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(''); + expect(html).toContain(''); + }); + + 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(''); + expect(html).toContain(''); + }); + + 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"'); + }); + }); +});