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 }>>;
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
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 {
@@ -567,6 +568,44 @@ export class MCPServer {
}
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 {

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[]> {
const allPosts = await this.getAllPostsUnpaginated();
const tags = new Set<string>();

View File

@@ -39,6 +39,7 @@ export interface BlogToolDeps {
categoryCount: 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: {
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({
description: 'List all categories used across blog posts, with the count of posts in each category.',
inputSchema: z.object({}),