diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index 3571945..9a659cf 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -53,7 +53,7 @@ interface PostEngineContract { getAllPosts: (options?: PaginationOptions) => Promise>; getPost: (id: string) => Promise; searchPosts: (query: string) => Promise; - searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>; createPost: (data: Partial) => Promise; updatePost: (id: string, data: Partial) => Promise; publishPost: (id: string) => Promise; @@ -505,7 +505,7 @@ export class MCPServer { // ── search_posts ── server.registerTool('search_posts', { title: 'Search Posts', - description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it) and linksTo (posts it links to). Use check_term first if unsure whether a term is a category or tag. Also available as resources: bds://categories (categories with counts), bds://tags (tags with counts).', + description: 'Search blog posts by query, category, tags, or date range. Returns a paginated envelope with total (matching count), offset, limit, hasMore, and posts array. Each post includes backlinks (posts linking to it) and linksTo (posts it links to). When hasMore is true, increase offset by limit to fetch the next page. Use check_term first if unsure whether a term is a category or tag. Also available as resources: bds://categories (categories with counts), bds://tags (tags with counts).', inputSchema: { query: z.string().optional().describe('Full-text search query'), category: z.string().optional().describe('Filter by category'), @@ -529,32 +529,37 @@ export class MCPServer { const offset = args.offset ?? 0; const limit = args.limit ?? 50; + let enriched; + let total: number; + if (args.query && !hasFilters) { const results = await this.deps.postEngine.searchPosts(args.query); - const paginated = results.slice(offset, offset + limit); - const enriched = await enrichWithLinks(paginated, this.deps.postEngine); - return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] }; - } - - const filter: PostFilter = {}; - 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; - - let enriched; - if (args.query && hasFilters) { - const results = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit }); - enriched = await enrichWithLinks(results, this.deps.postEngine); - } else { - const results = await this.deps.postEngine.getPostsFiltered(filter); + total = results.length; const paginated = results.slice(offset, offset + limit); enriched = await enrichWithLinks(paginated, this.deps.postEngine); + } else { + const filter: PostFilter = {}; + 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; + + if (args.query && hasFilters) { + const { posts, total: t } = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit }); + total = t; + enriched = await enrichWithLinks(posts, this.deps.postEngine); + } else { + const results = await this.deps.postEngine.getPostsFiltered(filter); + total = results.length; + const paginated = results.slice(offset, offset + limit); + enriched = await enrichWithLinks(paginated, this.deps.postEngine); + } } + const envelope = { total, offset, limit, hasMore: offset + limit < total, posts: enriched }; const content: Array<{ type: 'text'; text: string }> = [ - { type: 'text' as const, text: JSON.stringify(enriched) }, + { type: 'text' as const, text: JSON.stringify(envelope) }, ]; const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags); if (hintsList.length > 0) { diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 2aad68b..b4c96c9 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -891,11 +891,11 @@ export class PostEngine extends EventEmitter { query: string, filter: PostFilter, pagination?: PaginationOptions, - ): Promise { - if (!query.trim()) return []; + ): Promise<{ posts: PostData[]; total: number }> { + if (!query.trim()) return { posts: [], total: 0 }; const client = getDatabase().getLocalClient(); - if (!client) return []; + if (!client) return { posts: [], total: 0 }; try { const stemmedQuery = stemQuery(query, this.searchLanguage); @@ -974,12 +974,13 @@ export class PostEngine extends EventEmitter { } // Apply pagination + const total = postDataList.length; const offset = pagination?.offset ?? 0; const limit = pagination?.limit ?? postDataList.length; - return postDataList.slice(offset, offset + limit); + return { posts: postDataList.slice(offset, offset + limit), total }; } catch (error) { console.error('Search with filters failed:', error); - return []; + return { posts: [], total: 0 }; } } diff --git a/src/main/engine/ai/blog-tools.ts b/src/main/engine/ai/blog-tools.ts index 5384199..e40ed2d 100644 --- a/src/main/engine/ai/blog-tools.ts +++ b/src/main/engine/ai/blog-tools.ts @@ -21,7 +21,7 @@ export interface BlogToolDeps { getPost: (id: string) => Promise; getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>; getPostsFiltered: (filter: PostFilter) => Promise; - searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>; getCategoriesWithCounts: () => Promise>; getTagsWithCounts: () => Promise>; getLinkedBy: (postId: string) => Promise>; @@ -184,8 +184,7 @@ export function createBlogTools(deps: BlogToolDeps) { const offset = off ?? 0; const limit = lim ?? 10; - const filteredPosts = await postEngine.searchPostsFiltered(query, filter, { offset, limit }); - const totalMatches = filteredPosts.length; + const { posts: filteredPosts, total: totalMatches } = await postEngine.searchPostsFiltered(query, filter, { offset, limit }); const hints = await buildAmbiguityHints(postEngine, category, tags); const posts = await enrichWithLinks( @@ -202,7 +201,7 @@ export function createBlogTools(deps: BlogToolDeps) { success: true, count: posts.length, totalMatches, - hasMore: false, + hasMore: offset + limit < totalMatches, offset, limit, posts, diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts index 3d847f6..9375a0d 100644 --- a/tests/engine/MCPServer.integration.test.ts +++ b/tests/engine/MCPServer.integration.test.ts @@ -22,7 +22,7 @@ function createMockDeps(): MCPServerDependencies { getLinkedBy: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]), getPostsFiltered: vi.fn().mockResolvedValue([]), - searchPostsFiltered: vi.fn().mockResolvedValue([]), + searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }), }), getMediaEngine: () => ({ getAllMedia: vi.fn().mockResolvedValue([]), diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index e63ec5d..8f0572c 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -8,7 +8,7 @@ function createMockPostEngine() { getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }), getPost: vi.fn().mockResolvedValue(null), searchPosts: vi.fn().mockResolvedValue([]), - searchPostsFiltered: vi.fn().mockResolvedValue([]), + searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }), createPost: vi.fn().mockResolvedValue({ id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft', tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(), @@ -727,7 +727,11 @@ describe('MCPServer', () => { const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> }; expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test'); const parsed = JSON.parse(result.content[0].text); - expect(parsed[0].backlinks).toEqual([{ id: 'px', title: 'Ref', slug: 'ref' }]); + expect(parsed.posts[0].backlinks).toEqual([{ id: 'px', title: 'Ref', slug: 'ref' }]); + expect(parsed.total).toBe(1); + expect(parsed.offset).toBe(0); + expect(parsed.limit).toBe(50); + expect(parsed.hasMore).toBe(false); }); it('search_posts with query applies offset and limit', async () => { @@ -737,9 +741,11 @@ describe('MCPServer', () => { const tool = getTool(mcpServer, 'search_posts'); const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> }; const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveLength(3); - expect(parsed[0].id).toBe('p2'); - expect(parsed[2].id).toBe('p4'); + expect(parsed.posts).toHaveLength(3); + expect(parsed.posts[0].id).toBe('p2'); + expect(parsed.posts[2].id).toBe('p4'); + expect(parsed.total).toBe(10); + expect(parsed.hasMore).toBe(true); }); it('search_posts defaults to limit 50 when not specified', async () => { @@ -749,7 +755,9 @@ describe('MCPServer', () => { const tool = getTool(mcpServer, 'search_posts'); const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> }; const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveLength(50); + expect(parsed.posts).toHaveLength(50); + expect(parsed.total).toBe(60); + expect(parsed.hasMore).toBe(true); }); it('search_posts with filters only calls getPostsFiltered and includes backlinks', async () => { @@ -761,7 +769,9 @@ describe('MCPServer', () => { const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> }; expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' }); const parsed = JSON.parse(result.content[0].text); - expect(parsed[0].backlinks).toEqual([]); + expect(parsed.posts[0].backlinks).toEqual([]); + expect(parsed.total).toBe(1); + expect(parsed.hasMore).toBe(false); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p2'); }); @@ -772,15 +782,17 @@ describe('MCPServer', () => { const tool = getTool(mcpServer, 'search_posts'); const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> }; const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveLength(2); - expect(parsed[0].id).toBe('p3'); + expect(parsed.posts).toHaveLength(2); + expect(parsed.posts[0].id).toBe('p3'); + expect(parsed.total).toBe(10); + expect(parsed.hasMore).toBe(true); }); it('search_posts with query + filters calls searchPostsFiltered and includes backlinks', async () => { const combined = [ { id: 'p1', title: 'TypeScript Guide', categories: ['tech'] }, ]; - mockPostEngine.searchPostsFiltered.mockResolvedValue(combined); + mockPostEngine.searchPostsFiltered.mockResolvedValue({ posts: combined, total: 1 }); mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'p9', title: 'See Also', slug: 'see-also' }]); const mcpServer = server.createMcpServer(); const tool = getTool(mcpServer, 'search_posts'); @@ -794,25 +806,30 @@ describe('MCPServer', () => { expect(mockPostEngine.searchPosts).not.toHaveBeenCalled(); expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled(); const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveLength(1); - expect(parsed[0].id).toBe('p1'); - expect(parsed[0].backlinks).toEqual([{ id: 'p9', title: 'See Also', slug: 'see-also' }]); + expect(parsed.posts).toHaveLength(1); + expect(parsed.posts[0].id).toBe('p1'); + expect(parsed.posts[0].backlinks).toEqual([{ id: 'p9', title: 'See Also', slug: 'see-also' }]); + expect(parsed.total).toBe(1); + expect(parsed.hasMore).toBe(false); }); it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => { - mockPostEngine.searchPostsFiltered.mockResolvedValue([{ id: 'p3', title: 'Result' }]); + mockPostEngine.searchPostsFiltered.mockResolvedValue({ posts: [{ id: 'p3', title: 'Result' }], total: 20 }); const mcpServer = server.createMcpServer(); const tool = getTool(mcpServer, 'search_posts'); - await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {}); + const result = await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {}) as { content: Array<{ text: string }> }; expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith( 'keyword', { status: 'published' }, { offset: 5, limit: 10 }, ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.total).toBe(20); + expect(parsed.hasMore).toBe(true); }); it('search_posts with query + multiple filters builds correct filter', async () => { - mockPostEngine.searchPostsFiltered.mockResolvedValue([]); + mockPostEngine.searchPostsFiltered.mockResolvedValue({ posts: [], total: 0 }); const mcpServer = server.createMcpServer(); const tool = getTool(mcpServer, 'search_posts'); await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {}); diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index 71dc27b..9c78cc9 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2419,9 +2419,9 @@ Published snapshot content`); }); describe('searchPostsFiltered', () => { - it('should return empty array for empty query', async () => { + it('should return empty result for empty query', async () => { const result = await postEngine.searchPostsFiltered('', {}); - expect(result).toEqual([]); + expect(result).toEqual({ posts: [], total: 0 }); }); it('should use FTS JOIN with posts table to combine search and filters', async () => { @@ -2432,8 +2432,9 @@ Published snapshot content`); }); const result = await postEngine.searchPostsFiltered('search term', { status: 'published' }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('p1'); + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe('p1'); + expect(result.total).toBe(1); // Verify SQL includes both MATCH and status filter const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined; @@ -2464,8 +2465,9 @@ Published snapshot content`); }); const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('p1'); + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe('p1'); + expect(result.total).toBe(1); }); it('should apply pagination with offset and limit', async () => { @@ -2475,9 +2477,21 @@ Published snapshot content`); mockLocalClient.execute.mockResolvedValueOnce({ rows }); const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 }); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('p1'); - expect(result[1].id).toBe('p2'); + expect(result.posts).toHaveLength(2); + expect(result.posts[0].id).toBe('p1'); + expect(result.posts[1].id).toBe('p2'); + expect(result.total).toBe(5); + }); + + it('should return total count reflecting tag filtering but not pagination', async () => { + const rows = Array.from({ length: 4 }, (_, i) => ({ + id: `p${i}`, projectId: 'test-project', title: `Post ${i}`, slug: `post-${i}`, excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: i < 3 ? '["js"]' : '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null, + })); + mockLocalClient.execute.mockResolvedValueOnce({ rows }); + + const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] }, { offset: 0, limit: 2 }); + expect(result.posts).toHaveLength(2); + expect(result.total).toBe(3); // 3 posts have 'js' tag, not 4 total }); }); diff --git a/tests/engine/blog-tools.test.ts b/tests/engine/blog-tools.test.ts index 556ab1e..e8fa53d 100644 --- a/tests/engine/blog-tools.test.ts +++ b/tests/engine/blog-tools.test.ts @@ -166,7 +166,7 @@ describe('Blog Tools — search_posts', () => { }); it('calls searchPostsFiltered with correct filter', async () => { - vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce([samplePost]); + vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce({ posts: [samplePost], total: 5 }); const result = await tools.search_posts.execute!( { query: 'hello', category: 'article', year: 2025 }, @@ -177,11 +177,11 @@ describe('Blog Tools — search_posts', () => { { categories: ['article'], year: 2025 }, { offset: 0, limit: 10 }, ); - expect(result).toMatchObject({ success: true, count: 1 }); + expect(result).toMatchObject({ success: true, count: 1, totalMatches: 5, hasMore: false }); }); it('includes ambiguity hints when category also exists as tag', async () => { - vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce([]); + vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce({ posts: [], total: 0 }); vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([ { tag: 'article', count: 2 }, ]);