fix: some fixes for mcp server and ai tools
This commit is contained in:
@@ -53,7 +53,7 @@ interface PostEngineContract {
|
||||
getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>;
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
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>;
|
||||
updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
||||
publishPost: (id: string) => Promise<PostData | null>;
|
||||
@@ -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) {
|
||||
|
||||
@@ -891,11 +891,11 @@ export class PostEngine extends EventEmitter {
|
||||
query: string,
|
||||
filter: PostFilter,
|
||||
pagination?: PaginationOptions,
|
||||
): Promise<PostData[]> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface BlogToolDeps {
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>;
|
||||
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 }>>;
|
||||
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
|
||||
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 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,
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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' }, {});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user