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

@@ -53,7 +53,7 @@ interface PostEngineContract {
getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>; getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>;
getPost: (id: string) => Promise<PostData | null>; getPost: (id: string) => Promise<PostData | null>;
searchPosts: (query: string) => Promise<SearchResult[]>; searchPosts: (query: string) => Promise<SearchResult[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<PostData[]>; searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>;
createPost: (data: Partial<PostData>) => Promise<PostData>; createPost: (data: Partial<PostData>) => Promise<PostData>;
updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>; updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
publishPost: (id: string) => Promise<PostData | null>; publishPost: (id: string) => Promise<PostData | null>;
@@ -505,7 +505,7 @@ export class MCPServer {
// ── search_posts ── // ── search_posts ──
server.registerTool('search_posts', { server.registerTool('search_posts', {
title: '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: { inputSchema: {
query: z.string().optional().describe('Full-text search query'), query: z.string().optional().describe('Full-text search query'),
category: z.string().optional().describe('Filter by category'), category: z.string().optional().describe('Filter by category'),
@@ -529,13 +529,15 @@ export class MCPServer {
const offset = args.offset ?? 0; const offset = args.offset ?? 0;
const limit = args.limit ?? 50; const limit = args.limit ?? 50;
let enriched;
let total: number;
if (args.query && !hasFilters) { if (args.query && !hasFilters) {
const results = await this.deps.postEngine.searchPosts(args.query); const results = await this.deps.postEngine.searchPosts(args.query);
total = results.length;
const paginated = results.slice(offset, offset + limit); const paginated = results.slice(offset, offset + limit);
const enriched = await enrichWithLinks(paginated, this.deps.postEngine); enriched = await enrichWithLinks(paginated, this.deps.postEngine);
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] }; } else {
}
const filter: PostFilter = {}; const filter: PostFilter = {};
if (args.category) filter.categories = [args.category]; if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags; if (args.tags) filter.tags = args.tags;
@@ -543,18 +545,21 @@ export class MCPServer {
if (args.month) filter.month = args.month; if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status; if (args.status) filter.status = args.status;
let enriched;
if (args.query && hasFilters) { if (args.query && hasFilters) {
const results = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit }); const { posts, total: t } = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit });
enriched = await enrichWithLinks(results, this.deps.postEngine); total = t;
enriched = await enrichWithLinks(posts, this.deps.postEngine);
} else { } else {
const results = await this.deps.postEngine.getPostsFiltered(filter); const results = await this.deps.postEngine.getPostsFiltered(filter);
total = results.length;
const paginated = results.slice(offset, offset + limit); const paginated = results.slice(offset, offset + limit);
enriched = await enrichWithLinks(paginated, this.deps.postEngine); 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 }> = [ 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); const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags);
if (hintsList.length > 0) { if (hintsList.length > 0) {

View File

@@ -891,11 +891,11 @@ export class PostEngine extends EventEmitter {
query: string, query: string,
filter: PostFilter, filter: PostFilter,
pagination?: PaginationOptions, pagination?: PaginationOptions,
): Promise<PostData[]> { ): Promise<{ posts: PostData[]; total: number }> {
if (!query.trim()) return []; if (!query.trim()) return { posts: [], total: 0 };
const client = getDatabase().getLocalClient(); const client = getDatabase().getLocalClient();
if (!client) return []; if (!client) return { posts: [], total: 0 };
try { try {
const stemmedQuery = stemQuery(query, this.searchLanguage); const stemmedQuery = stemQuery(query, this.searchLanguage);
@@ -974,12 +974,13 @@ export class PostEngine extends EventEmitter {
} }
// Apply pagination // Apply pagination
const total = postDataList.length;
const offset = pagination?.offset ?? 0; const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? postDataList.length; const limit = pagination?.limit ?? postDataList.length;
return postDataList.slice(offset, offset + limit); return { posts: postDataList.slice(offset, offset + limit), total };
} catch (error) { } catch (error) {
console.error('Search with filters failed:', error); console.error('Search with filters failed:', error);
return []; return { posts: [], total: 0 };
} }
} }

View File

@@ -21,7 +21,7 @@ export interface BlogToolDeps {
getPost: (id: string) => Promise<PostData | null>; getPost: (id: string) => Promise<PostData | null>;
getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>; getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>; getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<PostData[]>; searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>;
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>; getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>; getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>; getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
@@ -184,8 +184,7 @@ export function createBlogTools(deps: BlogToolDeps) {
const offset = off ?? 0; const offset = off ?? 0;
const limit = lim ?? 10; const limit = lim ?? 10;
const filteredPosts = await postEngine.searchPostsFiltered(query, filter, { offset, limit }); const { posts: filteredPosts, total: totalMatches } = await postEngine.searchPostsFiltered(query, filter, { offset, limit });
const totalMatches = filteredPosts.length;
const hints = await buildAmbiguityHints(postEngine, category, tags); const hints = await buildAmbiguityHints(postEngine, category, tags);
const posts = await enrichWithLinks( const posts = await enrichWithLinks(
@@ -202,7 +201,7 @@ export function createBlogTools(deps: BlogToolDeps) {
success: true, success: true,
count: posts.length, count: posts.length,
totalMatches, totalMatches,
hasMore: false, hasMore: offset + limit < totalMatches,
offset, offset,
limit, limit,
posts, posts,

View File

@@ -22,7 +22,7 @@ function createMockDeps(): MCPServerDependencies {
getLinkedBy: vi.fn().mockResolvedValue([]), getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]), getPostsFiltered: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]), searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
}), }),
getMediaEngine: () => ({ getMediaEngine: () => ({
getAllMedia: vi.fn().mockResolvedValue([]), getAllMedia: vi.fn().mockResolvedValue([]),

View File

@@ -8,7 +8,7 @@ function createMockPostEngine() {
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }), getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
getPost: vi.fn().mockResolvedValue(null), getPost: vi.fn().mockResolvedValue(null),
searchPosts: vi.fn().mockResolvedValue([]), searchPosts: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]), searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
createPost: vi.fn().mockResolvedValue({ createPost: vi.fn().mockResolvedValue({
id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft', id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft',
tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(), 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 }> }; const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test'); expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test');
const parsed = JSON.parse(result.content[0].text); 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 () => { it('search_posts with query applies offset and limit', async () => {
@@ -737,9 +741,11 @@ describe('MCPServer', () => {
const tool = getTool(mcpServer, 'search_posts'); const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> }; const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(3); expect(parsed.posts).toHaveLength(3);
expect(parsed[0].id).toBe('p2'); expect(parsed.posts[0].id).toBe('p2');
expect(parsed[2].id).toBe('p4'); 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 () => { it('search_posts defaults to limit 50 when not specified', async () => {
@@ -749,7 +755,9 @@ describe('MCPServer', () => {
const tool = getTool(mcpServer, 'search_posts'); const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> }; const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text); 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 () => { 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 }> }; const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' }); expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' });
const parsed = JSON.parse(result.content[0].text); 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'); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p2');
}); });
@@ -772,15 +782,17 @@ describe('MCPServer', () => {
const tool = getTool(mcpServer, 'search_posts'); const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> }; const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(2); expect(parsed.posts).toHaveLength(2);
expect(parsed[0].id).toBe('p3'); 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 () => { it('search_posts with query + filters calls searchPostsFiltered and includes backlinks', async () => {
const combined = [ const combined = [
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] }, { 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' }]); mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
const mcpServer = server.createMcpServer(); const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts'); const tool = getTool(mcpServer, 'search_posts');
@@ -794,25 +806,30 @@ describe('MCPServer', () => {
expect(mockPostEngine.searchPosts).not.toHaveBeenCalled(); expect(mockPostEngine.searchPosts).not.toHaveBeenCalled();
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled(); expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text); const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(1); expect(parsed.posts).toHaveLength(1);
expect(parsed[0].id).toBe('p1'); expect(parsed.posts[0].id).toBe('p1');
expect(parsed[0].backlinks).toEqual([{ id: 'p9', title: 'See Also', slug: 'see-also' }]); 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 () => { 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 mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts'); 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( expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
'keyword', 'keyword',
{ status: 'published' }, { status: 'published' },
{ offset: 5, limit: 10 }, { 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 () => { 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 mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts'); const tool = getTool(mcpServer, 'search_posts');
await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {}); await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {});

View File

@@ -2419,9 +2419,9 @@ Published snapshot content`);
}); });
describe('searchPostsFiltered', () => { 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('', {}); 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 () => { 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' }); const result = await postEngine.searchPostsFiltered('search term', { status: 'published' });
expect(result).toHaveLength(1); expect(result.posts).toHaveLength(1);
expect(result[0].id).toBe('p1'); expect(result.posts[0].id).toBe('p1');
expect(result.total).toBe(1);
// Verify SQL includes both MATCH and status filter // Verify SQL includes both MATCH and status filter
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined; 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'] }); const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] });
expect(result).toHaveLength(1); expect(result.posts).toHaveLength(1);
expect(result[0].id).toBe('p1'); expect(result.posts[0].id).toBe('p1');
expect(result.total).toBe(1);
}); });
it('should apply pagination with offset and limit', async () => { it('should apply pagination with offset and limit', async () => {
@@ -2475,9 +2477,21 @@ Published snapshot content`);
mockLocalClient.execute.mockResolvedValueOnce({ rows }); mockLocalClient.execute.mockResolvedValueOnce({ rows });
const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 }); const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 });
expect(result).toHaveLength(2); expect(result.posts).toHaveLength(2);
expect(result[0].id).toBe('p1'); expect(result.posts[0].id).toBe('p1');
expect(result[1].id).toBe('p2'); 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 () => { 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!( const result = await tools.search_posts.execute!(
{ query: 'hello', category: 'article', year: 2025 }, { query: 'hello', category: 'article', year: 2025 },
@@ -177,11 +177,11 @@ describe('Blog Tools — search_posts', () => {
{ categories: ['article'], year: 2025 }, { categories: ['article'], year: 2025 },
{ offset: 0, limit: 10 }, { 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 () => { 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([ vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'article', count: 2 }, { tag: 'article', count: 2 },
]); ]);