fix: some fixes for mcp server and ai tools

This commit is contained in:
2026-03-01 20:40:36 +01:00
parent a508981400
commit 3074fe461c
7 changed files with 95 additions and 59 deletions

View File

@@ -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([]),

View File

@@ -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' }, {});

View File

@@ -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
});
});

View File

@@ -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 },
]);