feat: add count_posts aggregation tool to AI SDK and MCP server
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
Reference in New Issue
Block a user