diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index c167e97..a5f09d2 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -14,60 +14,94 @@ import { reviewTemplateHtml, reviewMetadataHtml, } from './mcp-views'; +import type { + PostData, + PostFilter, + SearchResult, + PaginatedResult, + PaginationOptions, +} from './PostEngine'; +import type { MediaData } from './MediaEngine'; +import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine'; +import type { CreateTemplateInput, TemplateData, TemplateValidationResult } from './TemplateEngine'; +import type { ProjectMetadata } from './MetaEngine'; +import type { PostMediaLinkData } from './PostMediaEngine'; +import type { TagWithCount } from './TagEngine'; + +// ── Pagination helpers ───────────────────────────────────────────────── + +export const DEFAULT_PAGE_SIZE = 50; + +export function encodeCursor(offset: number): string { + return Buffer.from(String(offset)).toString('base64url'); +} + +export function decodeCursor(cursor: string): number { + try { + const offset = parseInt(Buffer.from(cursor, 'base64url').toString(), 10); + return Number.isNaN(offset) || offset < 0 ? 0 : offset; + } catch { + return 0; + } +} // ── Dependency contracts ────────────────────────────────────────────── -export interface PostFilter { - status?: 'draft' | 'published' | 'archived'; - tags?: string[]; - categories?: string[]; - year?: number; - month?: number; -} - interface PostEngineContract { - getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array>; hasMore: boolean; total: number }>; - getPost: (id: string) => Promise | null>; - searchPosts: (query: string) => Promise>; - searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise>>; - createPost: (data: Record) => Promise>; - updatePost: (id: string, data: Record) => Promise | null>; - publishPost: (id: string) => Promise | null>; + getAllPosts: (options?: PaginationOptions) => Promise>; + getPost: (id: string) => Promise; + searchPosts: (query: string) => Promise; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise; + createPost: (data: Partial) => Promise; + updatePost: (id: string, data: Partial) => Promise; + publishPost: (id: string) => Promise; deletePost: (id: string) => Promise; getTagsWithCounts: () => Promise>; getCategoriesWithCounts: () => Promise>; - getBlogStats: () => Promise>; + getBlogStats: () => Promise<{ + totalPosts: number; + draftCount: number; + publishedCount: number; + archivedCount: number; + oldestPostDate: Date | null; + newestPostDate: Date | null; + postsPerYear: Record; + tagCount: number; + categoryCount: number; + }>; getLinkedBy: (postId: string) => Promise>; getLinksTo: (postId: string) => Promise>; - getPostsFiltered: (filter: PostFilter) => Promise>>; + getPostsFiltered: (filter: PostFilter) => Promise; } interface MediaEngineContract { - getAllMedia: () => Promise>>; - getMedia: (id: string) => Promise | null>; - updateMedia: (id: string, data: Record) => Promise | null>; + getAllMedia: () => Promise; + getMedia: (id: string) => Promise; + updateMedia: (id: string, data: Partial) => Promise; getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise; } interface ScriptEngineContract { - createScript: (input: Record) => Promise>; + createScript: (input: CreateScriptInput) => Promise; + validateScript: (content: string) => Promise; } interface TemplateEngineContract { - createTemplate: (input: Record) => Promise>; + createTemplate: (input: CreateTemplateInput) => Promise; + validateTemplate: (content: string) => Promise; } interface MetaEngineContract { - getProjectMetadata: () => Promise | null>; + getProjectMetadata: () => Promise; } interface PostMediaEngineContract { - getLinkedMediaDataForPost: (postId: string) => Promise>>; - getLinkedPostsForMedia: (mediaId: string) => Promise>>; + getLinkedMediaDataForPost: (postId: string) => Promise>; + getLinkedPostsForMedia: (mediaId: string) => Promise; } interface TagEngineContract { - getTagsWithCounts: () => Promise>>; + getTagsWithCounts: () => Promise; } export interface MCPServerDependencies { @@ -80,6 +114,23 @@ export interface MCPServerDependencies { getTagEngine: () => TagEngineContract; } +/** + * Maps each proposal type to the shape of its stored data. + * Used to recover type safety when reading from the generic ProposalStore. + */ +export interface ProposalDataMap { + draftPost: { postId: string }; + proposeScript: CreateScriptInput; + proposeTemplate: CreateTemplateInput; + proposeMediaMetadata: { mediaId: string; changes: Partial }; + proposePostMetadata: { postId: string; changes: Partial }; +} + +/** Type-safe accessor for proposal data (bridges the generic ProposalStore). */ +function proposalData(proposal: { data: Record }): ProposalDataMap[T] { + return proposal.data as unknown as ProposalDataMap[T]; +} + // ── MCPServer engine ────────────────────────────────────────────────── export class MCPServer { @@ -228,27 +279,25 @@ export class MCPServer { try { switch (proposal.type) { case 'draftPost': { - const postId = proposal.data.postId as string; + const { postId } = proposalData<'draftPost'>(proposal); await this.deps.getPostEngine().publishPost(postId); break; } case 'proposeScript': { - await this.deps.getScriptEngine().createScript(proposal.data); + await this.deps.getScriptEngine().createScript(proposalData<'proposeScript'>(proposal)); break; } case 'proposeTemplate': { - await this.deps.getTemplateEngine().createTemplate(proposal.data); + await this.deps.getTemplateEngine().createTemplate(proposalData<'proposeTemplate'>(proposal)); break; } case 'proposeMediaMetadata': { - const mediaId = proposal.data.mediaId as string; - const changes = proposal.data.changes as Record; + const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal); await this.deps.getMediaEngine().updateMedia(mediaId, changes); break; } case 'proposePostMetadata': { - const postId = proposal.data.postId as string; - const changes = proposal.data.changes as Record; + const { postId, changes } = proposalData<'proposePostMetadata'>(proposal); await this.deps.getPostEngine().updatePost(postId, changes); break; } @@ -268,7 +317,7 @@ export class MCPServer { try { if (proposal.type === 'draftPost') { - const postId = proposal.data.postId as string; + const { postId } = proposalData<'draftPost'>(proposal); await this.deps.getPostEngine().deletePost(postId); } this.proposalStore.remove(proposalId); @@ -281,14 +330,25 @@ export class MCPServer { // ── 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('posts', 'bds://posts', { description: 'All blog posts (first page)' }, async () => { + const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE }); + const response: Record = { ...result }; + if (result.hasMore) { + response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(response) }] }; }); - 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('media', 'bds://media', { description: 'All media files (first page)' }, async () => { + const allMedia = await this.deps.getMediaEngine().getAllMedia(); + const items = allMedia.slice(0, DEFAULT_PAGE_SIZE); + const total = allMedia.length; + const hasMore = DEFAULT_PAGE_SIZE < total; + const response: Record = { items, total, hasMore }; + if (hasMore) { + response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(response) }] }; }); server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => { @@ -308,6 +368,31 @@ export class MCPServer { } private registerResourceTemplates(server: McpServer): void { + // ── Pagination templates ── + server.registerResource('posts-page', new ResourceTemplate('bds://posts{?cursor}', { list: undefined }), { description: 'Paginated blog posts (use cursor from previous page)' }, async (uri, { cursor }) => { + const offset = decodeCursor(cursor as string); + const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE, offset }); + const response: Record = { ...result }; + if (result.hasMore) { + response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] }; + }); + + server.registerResource('media-page', new ResourceTemplate('bds://media{?cursor}', { list: undefined }), { description: 'Paginated media files (use cursor from previous page)' }, async (uri, { cursor }) => { + const offset = decodeCursor(cursor as string); + const allMedia = await this.deps.getMediaEngine().getAllMedia(); + const items = allMedia.slice(offset, offset + DEFAULT_PAGE_SIZE); + const total = allMedia.length; + const hasMore = offset + DEFAULT_PAGE_SIZE < total; + const response: Record = { items, total, hasMore }; + if (hasMore) { + response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] }; + }); + + // ── Entity templates ── 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) }] }; @@ -341,7 +426,7 @@ export class MCPServer { 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/')) { + if (!media || !media.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'); @@ -433,7 +518,7 @@ export class MCPServer { author: args.author, status: 'draft', }); - const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record).id }); + const proposalId = this.proposalStore.create('draftPost', { postId: post.id }); return { content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }], }; @@ -458,6 +543,7 @@ export class MCPServer { 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 validation = await this.deps.getScriptEngine().validateScript(args.content); const proposalId = this.proposalStore.create('proposeScript', { title: args.title, kind: args.kind, @@ -465,7 +551,7 @@ export class MCPServer { entrypoint: args.entrypoint, }); return { - content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }], }; }); @@ -481,13 +567,14 @@ export class MCPServer { 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 validation = await this.deps.getTemplateEngine().validateTemplate(args.content); 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 } }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }], }; }); diff --git a/src/main/engine/ScriptEngine.ts b/src/main/engine/ScriptEngine.ts index 2072513..87cfecd 100644 --- a/src/main/engine/ScriptEngine.ts +++ b/src/main/engine/ScriptEngine.ts @@ -57,6 +57,11 @@ export interface ScriptReconcileResult { processedFiles: number; } +export interface ScriptValidationResult { + valid: boolean; + errors: string[]; +} + interface ParsedScriptFile { metadata: { id?: string; @@ -86,6 +91,45 @@ export class ScriptEngine extends EventEmitter { return this.currentProjectId; } + async validateScript(content: string): Promise { + const script = [ + 'import ast, sys, json', + 'code = sys.stdin.read()', + 'try:', + ' ast.parse(code)', + ' json.dump({"valid": True, "errors": []}, sys.stdout)', + 'except SyntaxError as e:', + ' msg = e.msg or "invalid syntax"', + ' line = e.lineno or 1', + ' col = e.offset or 1', + ' json.dump({"valid": False, "errors": [f"{msg} (line {line}, col {col})"]}, sys.stdout)', + ].join('\n'); + + try { + const { execFile } = await import('node:child_process'); + return await new Promise((resolve) => { + const proc = execFile('python3', ['-c', script], { timeout: 5000 }, (error, stdout) => { + if (error && !stdout) { + // python3 not available or timed out — assume valid (can't check) + resolve({ valid: true, errors: [] }); + return; + } + try { + const result = JSON.parse(stdout); + resolve({ valid: !!result.valid, errors: Array.isArray(result.errors) ? result.errors : [] }); + } catch { + resolve({ valid: true, errors: [] }); + } + }); + proc.stdin?.write(content); + proc.stdin?.end(); + }); + } catch { + // Dynamic import failed — assume valid + return { valid: true, errors: [] }; + } + } + async createScript(input: CreateScriptInput): Promise { const now = new Date(); const allScripts = await this.getAllScriptRows(); diff --git a/src/main/main.ts b/src/main/main.ts index 28ec7a1..69c3a3c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -871,13 +871,13 @@ app.whenReady().then(async () => { } try { 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, + getPostEngine: () => getPostEngine(), + getMediaEngine: () => getMediaEngine(), + getScriptEngine: () => getScriptEngine(), + getTemplateEngine: () => getTemplateEngine(), + getMetaEngine: () => getMetaEngine(), + getPostMediaEngine: () => getPostMediaEngine(), + getTagEngine: () => getTagEngine(), }); await mcpServer.start(MCP_SERVER_PORT); } catch (error) { diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 909f8f6..e0d92ab 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer'; +import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer'; // Mock all engine singletons vi.mock('../../src/main/engine/PostEngine', () => ({ @@ -65,6 +65,7 @@ function createMockScriptEngine() { entrypoint: 'main.py', content: '', enabled: true, version: 1, filePath: '/test', createdAt: new Date(), updatedAt: new Date(), }), + validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }), }; } @@ -75,6 +76,7 @@ function createMockTemplateEngine() { enabled: true, version: 1, filePath: '/test', content: '', createdAt: new Date(), updatedAt: new Date(), }), + validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }), }; } @@ -270,6 +272,34 @@ describe('MCPServer', () => { const mcpServer = server.createMcpServer(); expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true); }); + + it('registers posts-page resource template for pagination', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'posts-page')).toBe(true); + }); + + it('registers media-page resource template for pagination', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-page')).toBe(true); + }); + }); + + describe('cursor encoding', () => { + it('round-trips offset through encode/decode', () => { + expect(decodeCursor(encodeCursor(0))).toBe(0); + expect(decodeCursor(encodeCursor(50))).toBe(50); + expect(decodeCursor(encodeCursor(999))).toBe(999); + }); + + it('returns 0 for invalid cursor', () => { + expect(decodeCursor('!!!invalid!!!')).toBe(0); + expect(decodeCursor('')).toBe(0); + }); + + it('returns 0 for negative offset', () => { + // Encoding a negative offset then decoding should clamp to 0 + expect(decodeCursor(encodeCursor(-5))).toBe(0); + }); }); describe('registered prompts', () => { @@ -490,16 +520,71 @@ describe('MCPServer', () => { return (mcpServer as Record Promise }>>)._registeredResourceTemplates[name]; } - it('bds://posts calls getAllPosts and returns JSON', async () => { + it('bds://posts calls getAllPosts with pagination limit 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(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE }); expect(JSON.parse(result.contents[0].text)).toEqual(postsData); }); + it('bds://posts includes nextCursor when hasMore is true', async () => { + const postsData = { + items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${i}` })), + hasMore: true, + total: 120, + }; + 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 }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.nextCursor).toBeTruthy(); + expect(parsed.hasMore).toBe(true); + expect(parsed.total).toBe(120); + // Cursor should decode to the next offset + expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE); + }); + + it('bds://posts omits nextCursor when hasMore is false', async () => { + const postsData = { items: [{ id: 'p1' }], 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 }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('bds://media returns paginated response with items, total, hasMore', async () => { + const allMedia = Array.from({ length: 3 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://media'); + const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(3); + expect(parsed.total).toBe(3); + expect(parsed.hasMore).toBe(false); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('bds://media includes nextCursor when more items than page size', async () => { + const allMedia = Array.from({ length: DEFAULT_PAGE_SIZE + 10 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://media'); + const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE); + expect(parsed.total).toBe(DEFAULT_PAGE_SIZE + 10); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBeTruthy(); + expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE); + }); + it('bds://stats calls getBlogStats and returns JSON', async () => { const stats = { totalPosts: 42 }; mockPostEngine.getBlogStats.mockResolvedValue(stats); @@ -519,6 +604,61 @@ describe('MCPServer', () => { expect(JSON.parse(result.contents[0].text)).toEqual(post); }); + it('posts-page template decodes cursor and passes offset to getAllPosts', async () => { + const cursor = encodeCursor(50); + mockPostEngine.getAllPosts.mockResolvedValue({ items: [{ id: 'p50' }], hasMore: false, total: 60 }); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'posts-page'); + const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE, offset: 50 }); + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toEqual([{ id: 'p50' }]); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('posts-page template includes nextCursor for subsequent pages', async () => { + const cursor = encodeCursor(50); + mockPostEngine.getAllPosts.mockResolvedValue({ + items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${50 + i}` })), + hasMore: true, + total: 200, + }); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'posts-page'); + const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.nextCursor).toBeTruthy(); + expect(decodeCursor(parsed.nextCursor)).toBe(100); + }); + + it('media-page template decodes cursor and returns correct slice', async () => { + const allMedia = Array.from({ length: 80 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const cursor = encodeCursor(50); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-page'); + const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(30); + expect(parsed.items[0].id).toBe('m50'); + expect(parsed.total).toBe(80); + expect(parsed.hasMore).toBe(false); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('media-page template includes nextCursor when more pages remain', async () => { + const allMedia = Array.from({ length: 120 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const cursor = encodeCursor(0); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-page'); + const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE); + expect(parsed.hasMore).toBe(true); + expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE); + }); + it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => { const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }]; mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia); @@ -693,6 +833,50 @@ describe('MCPServer', () => { expect(proposal!.data.content).toBe('print("hi")'); }); + it('propose_script calls validateScript and includes validation result in preview', async () => { + mockScriptEngine.validateScript.mockResolvedValue({ valid: true, errors: [] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_script'); + const result = await tool.handler({ title: 'Valid Script', kind: 'macro', content: 'print("hello")' }, {}) as { content: Array<{ text: string }> }; + expect(mockScriptEngine.validateScript).toHaveBeenCalledWith('print("hello")'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.preview.syntaxValid).toBe(true); + expect(parsed.preview.syntaxErrors).toEqual([]); + }); + + it('propose_script includes syntax errors in preview when validation fails', async () => { + mockScriptEngine.validateScript.mockResolvedValue({ valid: false, errors: ['invalid syntax (line 1, col 6)'] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_script'); + const result = await tool.handler({ title: 'Bad Script', kind: 'macro', content: 'def (' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.proposalId).toBeTruthy(); + expect(parsed.preview.syntaxValid).toBe(false); + expect(parsed.preview.syntaxErrors).toEqual(['invalid syntax (line 1, col 6)']); + }); + + it('propose_template calls validateTemplate and includes validation result in preview', async () => { + mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: true, errors: [] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_template'); + const result = await tool.handler({ title: 'Valid Template', kind: 'post', content: '

{{ title }}

' }, {}) as { content: Array<{ text: string }> }; + expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('

{{ title }}

'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.preview.syntaxValid).toBe(true); + expect(parsed.preview.syntaxErrors).toEqual([]); + }); + + it('propose_template includes syntax errors in preview when validation fails', async () => { + mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: false, errors: ['tag "{% invalid" not closed'] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_template'); + const result = await tool.handler({ title: 'Bad Template', kind: 'post', content: '{% invalid' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.proposalId).toBeTruthy(); + expect(parsed.preview.syntaxValid).toBe(false); + expect(parsed.preview.syntaxErrors).toEqual(['tag "{% invalid" not closed']); + }); + 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); diff --git a/tests/engine/ScriptEngine.test.ts b/tests/engine/ScriptEngine.test.ts index fa450cb..36d6f86 100644 --- a/tests/engine/ScriptEngine.test.ts +++ b/tests/engine/ScriptEngine.test.ts @@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as fs from 'fs/promises'; import { ScriptEngine } from '../../src/main/engine/ScriptEngine'; +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: mockExecFile, + default: { execFile: mockExecFile }, +})); + const mockScripts = new Map(); const mockFiles = new Map(); @@ -377,4 +386,63 @@ describe('ScriptEngine', () => { expect(found).toBeNull(); }); }); + + describe('validateScript', () => { + it('returns valid for correct Python syntax', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, JSON.stringify({ valid: true, errors: [] })); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('print("hello")'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('returns invalid with errors for bad Python syntax', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, JSON.stringify({ valid: false, errors: ['invalid syntax (line 1, col 5)'] })); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('def ('); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(['invalid syntax (line 1, col 5)']); + }); + + it('returns valid when python3 is not available', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error, stdout: string) => void) => { + cb(new Error('spawn python3 ENOENT'), ''); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('print("hello")'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('returns valid when python3 output is not parseable JSON', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, 'not json'); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('print("hello")'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('passes script content via stdin', async () => { + const writeFn = vi.fn(); + const endFn = vi.fn(); + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, JSON.stringify({ valid: true, errors: [] })); + return { stdin: { write: writeFn, end: endFn } }; + }); + + await scriptEngine.validateScript('x = 42'); + expect(writeFn).toHaveBeenCalledWith('x = 42'); + expect(endFn).toHaveBeenCalled(); + }); + }); });