feat: add count_posts aggregation tool to AI SDK and MCP server

This commit is contained in:
2026-03-01 20:46:44 +01:00
parent 3074fe461c
commit db84129a17
8 changed files with 398 additions and 2 deletions

View File

@@ -74,6 +74,7 @@ interface PostEngineContract {
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>; getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>; getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>; getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
} }
interface MediaEngineContract { interface MediaEngineContract {
@@ -567,6 +568,44 @@ export class MCPServer {
} }
return { content }; return { content };
}); });
// ── count_posts ──
server.registerTool('count_posts', {
title: 'Count Posts',
description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without full post data — ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.',
inputSchema: {
groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'),
year: z.number().optional().describe('Filter to posts in this year'),
month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
if (args.month && !args.year) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }],
isError: true,
};
}
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
if (args.year !== undefined) filter.year = args.year;
if (args.month !== undefined) filter.month = args.month;
if (args.status) filter.status = args.status;
if (args.category) filter.category = args.category;
if (args.tags) filter.tags = args.tags;
const result = await this.deps.postEngine.getPostCounts(
args.groupBy,
Object.keys(filter).length > 0 ? filter : undefined,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
};
});
} }
private registerProposalTools(server: McpServer): void { private registerProposalTools(server: McpServer): void {

View File

@@ -984,6 +984,127 @@ export class PostEngine extends EventEmitter {
} }
} }
/**
* Server-side aggregation: count posts grouped by one or more dimensions.
* Returns flat groups with counts — avoids transferring full post data for analytics.
*/
async getPostCounts(
groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>,
filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] },
): Promise<{ groups: Record<string, string | number>[]; totalPosts: number }> {
const client = getDatabase().getLocalClient();
if (!client) return { groups: [], totalPosts: 0 };
// Build SELECT expressions and GROUP BY columns
const selectExprs: string[] = [];
const groupByCols: string[] = [];
const joins: string[] = [];
for (const dim of groupBy) {
switch (dim) {
case 'year':
selectExprs.push("CAST(strftime('%Y', posts.created_at) AS INTEGER) AS g_year");
groupByCols.push('g_year');
break;
case 'month':
selectExprs.push("CAST(strftime('%m', posts.created_at) AS INTEGER) AS g_month");
groupByCols.push('g_month');
break;
case 'tag':
selectExprs.push('t.value AS g_tag');
joins.push('JOIN json_each(posts.tags) AS t');
groupByCols.push('g_tag');
break;
case 'category':
selectExprs.push('c.value AS g_category');
joins.push('JOIN json_each(posts.categories) AS c');
groupByCols.push('g_category');
break;
case 'status':
selectExprs.push('posts.status AS g_status');
groupByCols.push('g_status');
break;
}
}
selectExprs.push('COUNT(*) AS cnt');
// Build WHERE conditions
const conditions: string[] = ['posts.project_id = ?'];
const args: (string | number)[] = [this.currentProjectId];
if (filter?.year !== undefined) {
const start = `${filter.year}-01-01`;
const end = `${filter.year + 1}-01-01`;
conditions.push('posts.created_at >= ?');
args.push(start);
conditions.push('posts.created_at < ?');
args.push(end);
}
if (filter?.month !== undefined && filter?.year !== undefined) {
const start = `${filter.year}-${String(filter.month).padStart(2, '0')}-01`;
const endMonth = filter.month === 12 ? 1 : filter.month + 1;
const endYear = filter.month === 12 ? filter.year + 1 : filter.year;
const end = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
conditions.push('posts.created_at >= ?');
args.push(start);
conditions.push('posts.created_at < ?');
args.push(end);
}
if (filter?.status) {
conditions.push('posts.status = ?');
args.push(filter.status);
}
if (filter?.category) {
conditions.push(
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS fc WHERE fc.value = ?)`,
);
args.push(filter.category);
}
if (filter?.tags && filter.tags.length > 0) {
for (const tag of filter.tags) {
conditions.push(
`EXISTS (SELECT 1 FROM json_each(posts.tags) AS ft WHERE ft.value = ?)`,
);
args.push(tag);
}
}
const sql = `
SELECT ${selectExprs.join(', ')}
FROM posts
${joins.join(' ')}
WHERE ${conditions.join(' AND ')}
GROUP BY ${groupByCols.join(', ')}
ORDER BY cnt DESC
`;
try {
const result = await client.execute({ sql, args });
// Map dimension aliases back to clean names
const dimMap: Record<string, string> = {
g_year: 'year', g_month: 'month', g_tag: 'tag',
g_category: 'category', g_status: 'status',
};
const groups: Record<string, string | number>[] = result.rows.map((row: any) => {
const group: Record<string, string | number> = {};
for (const col of groupByCols) {
group[dimMap[col]] = row[col];
}
group.count = Number(row.cnt);
return group;
});
const totalPosts = groups.reduce((sum, g) => sum + (g.count as number), 0);
return { groups, totalPosts };
} catch (error) {
console.error('getPostCounts failed:', error);
return { groups: [], totalPosts: 0 };
}
}
async getAvailableTags(): Promise<string[]> { async getAvailableTags(): Promise<string[]> {
const allPosts = await this.getAllPostsUnpaginated(); const allPosts = await this.getAllPostsUnpaginated();
const tags = new Set<string>(); const tags = new Set<string>();

View File

@@ -39,6 +39,7 @@ export interface BlogToolDeps {
categoryCount: number; categoryCount: number;
}>; }>;
getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>; getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>;
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
}; };
mediaEngine: { mediaEngine: {
getMedia: (id: string) => Promise<MediaData | null>; getMedia: (id: string) => Promise<MediaData | null>;
@@ -444,6 +445,37 @@ export function createBlogTools(deps: BlogToolDeps) {
}, },
}), }),
count_posts: tool({
description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without transferring full post data. Ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.',
inputSchema: z.object({
groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'),
year: z.number().optional().describe('Filter to posts in this year'),
month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
}),
execute: async ({ groupBy, year, month, status, category, tags }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
if (year !== undefined) filter.year = year;
if (month !== undefined) filter.month = month;
if (status) filter.status = status;
if (category) filter.category = category;
if (tags && tags.length > 0) filter.tags = tags;
const result = await postEngine.getPostCounts(groupBy, Object.keys(filter).length > 0 ? filter : undefined);
return {
success: true,
groupCount: result.groups.length,
totalPosts: result.totalPosts,
groups: result.groups,
};
},
}),
list_categories: tool({ list_categories: tool({
description: 'List all categories used across blog posts, with the count of posts in each category.', description: 'List all categories used across blog posts, with the count of posts in each category.',
inputSchema: z.object({}), inputSchema: z.object({}),

View File

@@ -22,6 +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([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }), searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
}), }),
getMediaEngine: () => ({ getMediaEngine: () => ({

View File

@@ -25,6 +25,7 @@ function createMockPostEngine() {
getLinkedBy: vi.fn().mockResolvedValue([]), getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]), getPostsFiltered: vi.fn().mockResolvedValue([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
}; };
} }
@@ -190,6 +191,11 @@ describe('MCPServer', () => {
expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true); expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true);
}); });
it('registers count_posts tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'count_posts')).toBe(true);
});
it('registers accept_proposal tool', () => { it('registers accept_proposal tool', () => {
const mcpServer = server.createMcpServer(); const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true); expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true);
@@ -840,6 +846,35 @@ describe('MCPServer', () => {
); );
}); });
// ── count_posts ──────────────────────────────────────────────────
it('count_posts calls getPostCounts with correct args', async () => {
mockPostEngine.getPostCounts.mockResolvedValue({
groups: [
{ month: 1, tag: 'Politik', count: 12 },
{ month: 2, tag: 'Politik', count: 5 },
],
totalPosts: 300,
});
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'count_posts');
const result = await tool.handler({ groupBy: ['month', 'tag'], year: 2004 }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostCounts).toHaveBeenCalledWith(
['month', 'tag'],
{ year: 2004 },
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalPosts).toBe(300);
expect(parsed.groups).toHaveLength(2);
});
it('count_posts returns error when month without year', async () => {
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'count_posts');
const result = await tool.handler({ groupBy: ['tag'], month: 6 }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
});
it('draft_post creates a draft and stores proposal', async () => { it('draft_post creates a draft and stores proposal', async () => {
const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' }; const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' };
mockPostEngine.createPost.mockResolvedValue(createdPost); mockPostEngine.createPost.mockResolvedValue(createdPost);

View File

@@ -2495,6 +2495,108 @@ Published snapshot content`);
}); });
}); });
describe('getPostCounts', () => {
it('should return empty groups when no posts exist', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
const result = await postEngine.getPostCounts(['year']);
expect(result).toEqual({ groups: [], totalPosts: 0 });
});
it('should group by year', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ g_year: 2024, cnt: 15 },
{ g_year: 2023, cnt: 10 },
],
});
const result = await postEngine.getPostCounts(['year']);
expect(result.groups).toEqual([
{ year: 2024, count: 15 },
{ year: 2023, count: 10 },
]);
expect(result.totalPosts).toBe(25);
});
it('should group by month and tag with year filter', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ g_month: 1, g_tag: 'Politik', cnt: 12 },
{ g_month: 1, g_tag: 'Medien', cnt: 8 },
{ g_month: 2, g_tag: 'Politik', cnt: 5 },
],
});
const result = await postEngine.getPostCounts(['month', 'tag'], { year: 2004 });
expect(result.groups).toEqual([
{ month: 1, tag: 'Politik', count: 12 },
{ month: 1, tag: 'Medien', count: 8 },
{ month: 2, tag: 'Politik', count: 5 },
]);
expect(result.totalPosts).toBe(25);
});
it('should group by category and status', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ g_category: 'article', g_status: 'published', cnt: 20 },
{ g_category: 'wiki', g_status: 'draft', cnt: 3 },
],
});
const result = await postEngine.getPostCounts(['category', 'status']);
expect(result.groups).toEqual([
{ category: 'article', status: 'published', count: 20 },
{ category: 'wiki', status: 'draft', count: 3 },
]);
});
it('should include year and month filters in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['tag'], { year: 2004, month: 6 });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('json_each');
expect(sql).toContain('group by');
expect(sql).toContain('created_at >=');
expect(sql).toContain('created_at <');
});
it('should include status filter in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['year'], { status: 'published' });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain("status = ?");
});
it('should include category filter in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['month'], { year: 2024, category: 'tech' });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('json_each');
expect(sql).toContain('categories');
});
it('should include tags filter in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['year'], { tags: ['js', 'react'] });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('json_each');
});
});
describe('getTagsWithCounts', () => { describe('getTagsWithCounts', () => {
it('should return empty array when no posts have tags', async () => { it('should return empty array when no posts have tags', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => { vi.mocked(mockLocalDb.select).mockImplementation(() => {

View File

@@ -54,6 +54,7 @@ function createMockBlogToolDeps(): BlogToolDeps {
getAllPosts: vi.fn(), getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(), getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(), searchPostsFiltered: vi.fn(),
getPostCounts: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]), getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]), getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]), getLinkedBy: vi.fn().mockResolvedValue([]),

View File

@@ -17,6 +17,7 @@ function createMockDeps(): BlogToolDeps {
getAllPosts: vi.fn(), getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(), getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(), searchPostsFiltered: vi.fn(),
getPostCounts: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]), getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]), getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]), getLinkedBy: vi.fn().mockResolvedValue([]),
@@ -71,9 +72,9 @@ describe('Blog Tools — createBlogTools', () => {
tools = createBlogTools(deps); tools = createBlogTools(deps);
}); });
it('returns all 16 tools', () => { it('returns all 17 tools', () => {
const names = Object.keys(tools); const names = Object.keys(tools);
expect(names).toHaveLength(16); expect(names).toHaveLength(17);
expect(names).toContain('check_term'); expect(names).toContain('check_term');
expect(names).toContain('search_posts'); expect(names).toContain('search_posts');
expect(names).toContain('read_post'); expect(names).toContain('read_post');
@@ -84,6 +85,7 @@ describe('Blog Tools — createBlogTools', () => {
expect(names).toContain('update_media_metadata'); expect(names).toContain('update_media_metadata');
expect(names).toContain('list_tags'); expect(names).toContain('list_tags');
expect(names).toContain('list_categories'); expect(names).toContain('list_categories');
expect(names).toContain('count_posts');
expect(names).toContain('get_blog_stats'); expect(names).toContain('get_blog_stats');
expect(names).toContain('view_image'); expect(names).toContain('view_image');
expect(names).toContain('get_post_backlinks'); expect(names).toContain('get_post_backlinks');
@@ -429,6 +431,69 @@ describe('Blog Tools — update_media_metadata', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// count_posts
// ---------------------------------------------------------------------------
describe('Blog Tools — count_posts', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('calls getPostCounts with groupBy and filters', async () => {
vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({
groups: [
{ month: 1, tag: 'Politik', count: 12 },
{ month: 2, tag: 'Politik', count: 5 },
],
totalPosts: 200,
});
const result = await tools.count_posts.execute!(
{ groupBy: ['month', 'tag'], year: 2004 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith(
['month', 'tag'],
{ year: 2004 },
);
expect(result).toMatchObject({
success: true,
totalPosts: 200,
groupCount: 2,
});
expect((result as any).groups).toHaveLength(2);
});
it('passes all optional filters', async () => {
vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({
groups: [],
totalPosts: 0,
});
await tools.count_posts.execute!(
{ groupBy: ['status'], year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith(
['status'],
{ year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] },
);
});
it('returns error when month without year', async () => {
const result = await tools.count_posts.execute!(
{ groupBy: ['tag'], month: 3 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// list_tags / list_categories // list_tags / list_categories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------