feat: mcp server round four
This commit is contained in:
@@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies {
|
||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
searchPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getMediaEngine: () => ({
|
||||
getAllMedia: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -29,6 +29,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([]),
|
||||
createPost: vi.fn().mockResolvedValue({
|
||||
id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft',
|
||||
tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(),
|
||||
@@ -618,20 +619,49 @@ describe('MCPServer', () => {
|
||||
expect(parsed[0].id).toBe('p3');
|
||||
});
|
||||
|
||||
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: '' },
|
||||
it('search_posts with query + filters calls searchPostsFiltered', async () => {
|
||||
const combined = [
|
||||
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
|
||||
];
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue(allFiltered);
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
|
||||
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();
|
||||
// Should call searchPostsFiltered, not searchPosts + getPostsFiltered separately
|
||||
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
|
||||
'typescript',
|
||||
{ categories: ['tech'] },
|
||||
{ offset: 0, limit: 50 },
|
||||
);
|
||||
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].title).toBe('TypeScript Guide');
|
||||
expect(parsed[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([{ id: 'p3', title: 'Result' }]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {});
|
||||
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
|
||||
'keyword',
|
||||
{ status: 'published' },
|
||||
{ offset: 5, limit: 10 },
|
||||
);
|
||||
});
|
||||
|
||||
it('search_posts with query + multiple filters builds correct filter', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {});
|
||||
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
|
||||
'test',
|
||||
{ categories: ['tech'], tags: ['js'], year: 2025, status: 'published' },
|
||||
{ offset: 0, limit: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
it('draft_post creates a draft and stores proposal', async () => {
|
||||
@@ -677,6 +707,18 @@ describe('MCPServer', () => {
|
||||
expect(proposal!.data.mediaId).toBe('img-1');
|
||||
});
|
||||
|
||||
it('propose_media_metadata returns error for non-existent media', async () => {
|
||||
mockMediaEngine.getMedia.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_media_metadata');
|
||||
const result = await tool.handler({ mediaId: 'no-such', alt: 'New alt' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
// No proposal should be created
|
||||
expect(server.proposalStore.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -689,6 +731,48 @@ describe('MCPServer', () => {
|
||||
const proposal = server.proposalStore.get(parsed.proposalId);
|
||||
expect(proposal!.type).toBe('proposePostMetadata');
|
||||
});
|
||||
|
||||
it('propose_post_metadata returns error for non-existent post', async () => {
|
||||
mockPostEngine.getPost.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_post_metadata');
|
||||
const result = await tool.handler({ postId: 'no-such', title: 'New Title' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
expect(server.proposalStore.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('draft_post returns error when createPost fails', async () => {
|
||||
mockPostEngine.createPost.mockRejectedValue(new Error('No active project'));
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'draft_post');
|
||||
const result = await tool.handler({ title: 'Test', content: '# Hello' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('No active project');
|
||||
expect(server.proposalStore.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('propose_media_metadata returns error when getMedia throws', async () => {
|
||||
mockMediaEngine.getMedia.mockRejectedValue(new Error('DB error'));
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_media_metadata');
|
||||
const result = await tool.handler({ mediaId: 'img-1', alt: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('DB error');
|
||||
});
|
||||
|
||||
it('propose_post_metadata returns error when getPost throws', async () => {
|
||||
mockPostEngine.getPost.mockRejectedValue(new Error('DB error'));
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_post_metadata');
|
||||
const result = await tool.handler({ postId: 'p1', title: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('DB error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt handler behavior ────────────────────────────────────────
|
||||
|
||||
@@ -2418,6 +2418,69 @@ Published snapshot content`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchPostsFiltered', () => {
|
||||
it('should return empty array for empty query', async () => {
|
||||
const result = await postEngine.searchPostsFiltered('', {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use FTS JOIN with posts table to combine search and filters', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ id: 'p1', projectId: 'test-project', title: 'Found', slug: 'found', excerpt: 'Excerpt', content: 'Content', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js"]', categories: '["tech"]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await postEngine.searchPostsFiltered('search term', { status: 'published' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('p1');
|
||||
|
||||
// Verify SQL includes both MATCH and status filter
|
||||
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
|
||||
const sql = call?.sql?.toLowerCase() ?? '';
|
||||
expect(sql).toContain('match');
|
||||
expect(sql).toContain('status');
|
||||
expect(sql).toContain('order by');
|
||||
expect(sql).toContain('rank');
|
||||
});
|
||||
|
||||
it('should apply category filter in SQL', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await postEngine.searchPostsFiltered('term', { categories: ['tech'] });
|
||||
|
||||
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
|
||||
const sql = call?.sql?.toLowerCase() ?? '';
|
||||
expect(sql).toContain('match');
|
||||
expect(sql).toContain('json_each');
|
||||
});
|
||||
|
||||
it('should apply tag filter client-side after SQL query', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ id: 'p1', projectId: 'test-project', title: 'Has Tag', slug: 'has-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js", "react"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
|
||||
{ id: 'p2', projectId: 'test-project', title: 'No Tag', slug: 'no-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should apply pagination with offset and limit', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, 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: '[]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null,
|
||||
}));
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagsWithCounts', () => {
|
||||
it('should return empty array when no posts have tags', async () => {
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
|
||||
Reference in New Issue
Block a user