feat: some better helps for AI assistants

This commit is contained in:
2026-03-01 17:29:45 +01:00
parent c8d72486f1
commit 7cc50e35ee
4 changed files with 486 additions and 13 deletions

View File

@@ -488,21 +488,60 @@ export class MCPServer {
// ── Tool registration ──────────────────────────────────────────────
private registerReadTools(server: McpServer): void {
// ── check_term ──
server.registerTool('check_term', {
title: 'Check Term',
description: 'Check whether a term exists as a category, tag, or both. Returns post counts for each. Useful for disambiguation before querying with search_posts.',
inputSchema: {
term: z.string().describe('The term to look up'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
const [categories, tags] = await Promise.all([
this.deps.postEngine.getCategoriesWithCounts(),
this.deps.postEngine.getTagsWithCounts(),
]);
const termLower = args.term.toLowerCase();
const catMatch = categories.find(c => c.category.toLowerCase() === termLower);
const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
term: args.term,
asCategory: !!catMatch,
categoryPostCount: catMatch?.count ?? 0,
asTag: !!tagMatch,
tagPostCount: tagMatch?.count ?? 0,
}),
}],
};
});
// ── 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).',
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).',
inputSchema: {
query: z.string().optional().describe('Full-text search query'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
year: z.number().optional().describe('Filter by year'),
month: z.number().optional().describe('Filter by month (1-12)'),
month: z.number().optional().describe('Filter by month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
offset: z.number().optional().describe('Pagination offset'),
limit: z.number().optional().describe('Max results to return'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
// Validate: month requires year
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 hasFilters = args.category || args.tags || args.year || args.month || args.status;
const offset = args.offset ?? 0;
const limit = args.limit ?? 50;
@@ -544,17 +583,63 @@ export class MCPServer {
args.query, filter, { offset, limit },
);
const enriched = await enrichWithLinks(results);
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
const content: Array<{ type: 'text'; text: string }> = [
{ type: 'text' as const, text: JSON.stringify(enriched) },
];
const hints = await this.buildAmbiguityHints(args.category, args.tags);
if (hints) {
content.push({ type: 'text' as const, text: hints });
}
return { content };
}
// Filter-only query (no text search)
const results = await this.deps.postEngine.getPostsFiltered(filter);
const paginated = results.slice(offset, offset + limit);
const enriched = await enrichWithLinks(paginated);
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
const content: Array<{ type: 'text'; text: string }> = [
{ type: 'text' as const, text: JSON.stringify(enriched) },
];
// Ambiguity hints: check if category/tag terms exist in the other namespace
const hints = await this.buildAmbiguityHints(args.category, args.tags);
if (hints) {
content.push({ type: 'text' as const, text: hints });
}
return { content };
});
}
/** Build a hint string when category/tag terms overlap across namespaces. */
private async buildAmbiguityHints(
category: string | undefined,
tags: string[] | undefined,
): Promise<string | null> {
const hints: string[] = [];
if (category) {
const allTags = await this.deps.postEngine.getTagsWithCounts();
const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase());
if (tagMatch) {
hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`);
}
}
if (tags && tags.length > 0) {
const allCats = await this.deps.postEngine.getCategoriesWithCounts();
for (const tag of tags) {
const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase());
if (catMatch) {
hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`);
}
}
}
return hints.length > 0 ? hints.join(' ') : null;
}
private registerProposalTools(server: McpServer): void {
// ── draft_post ──
registerAppTool(server, 'draft_post', {

View File

@@ -1123,15 +1123,26 @@ export class OpenCodeManager {
*/
private getToolDefinitions(): ToolDefinition[] {
return [
{
name: 'check_term',
description: 'Check whether a term exists as a category, tag, or both. Returns post counts for each. Use this before search_posts or list_posts when unsure whether a term is a category or tag.',
input_schema: {
type: 'object',
properties: {
term: { type: 'string', description: 'The term to look up' },
},
required: ['term'],
},
},
{
name: 'search_posts',
description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit.',
description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit. Use check_term first if unsure whether a term is a category or tag.',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query text to find in posts' },
category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' },
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by' },
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by (all must match)' },
year: { type: 'number', description: 'Filter to posts created in this year (e.g., 2024)' },
month: { type: 'number', description: 'Filter to posts created in this month (1-12). Requires year.' },
limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' },
@@ -1153,7 +1164,7 @@ export class OpenCodeManager {
},
{
name: 'list_posts',
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks (posts linking to it). The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results.',
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks (posts linking to it). The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results. Use check_term first if unsure whether a term is a category or tag.',
input_schema: {
type: 'object',
properties: {
@@ -1511,7 +1522,29 @@ export class OpenCodeManager {
private async executeTool(name: string, args: Record<string, unknown>): Promise<unknown> {
try {
switch (name) {
case 'check_term': {
const [categories, tags] = await Promise.all([
this.postEngine.getCategoriesWithCounts(),
this.postEngine.getTagsWithCounts(),
]);
const termLower = (args.term as string).toLowerCase();
const catMatch = categories.find(c => c.category.toLowerCase() === termLower);
const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower);
return {
success: true,
term: args.term as string,
asCategory: !!catMatch,
categoryPostCount: catMatch?.count ?? 0,
asTag: !!tagMatch,
tagPostCount: tagMatch?.count ?? 0,
};
}
case 'search_posts': {
if (args.month !== undefined && args.year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
if (args.category) filter.categories = [args.category as string];
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) filter.tags = args.tags as string[];
@@ -1530,8 +1563,9 @@ export class OpenCodeManager {
);
const totalMatches = filteredPosts.length;
const hints = await this.buildAmbiguityHints(args.category as string | undefined, args.tags as string[] | undefined);
return {
const result: Record<string, unknown> = {
success: true,
count: filteredPosts.length,
totalMatches,
@@ -1553,6 +1587,8 @@ export class OpenCodeManager {
};
})),
};
if (hints.length > 0) result.hints = hints;
return result;
}
case 'read_post': {
@@ -1578,6 +1614,10 @@ export class OpenCodeManager {
}
case 'list_posts': {
if (args.month !== undefined && args.year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
if (args.status) filter.status = args.status as 'draft' | 'published' | 'archived';
if (args.tags) filter.tags = args.tags as string[];
@@ -1600,12 +1640,14 @@ export class OpenCodeManager {
filteredTotal = allFiltered.length;
pageItems = allFiltered.slice(offset, offset + limit);
} else {
const result = await this.postEngine.getAllPosts({ limit, offset });
pageItems = result.items;
filteredTotal = result.total;
const listResult = await this.postEngine.getAllPosts({ limit, offset });
pageItems = listResult.items;
filteredTotal = listResult.total;
}
return {
const hints = await this.buildAmbiguityHints(args.category as string | undefined, args.tags as string[] | undefined);
const result: Record<string, unknown> = {
success: true,
count: pageItems.length,
total: globalTotal,
@@ -1627,6 +1669,8 @@ export class OpenCodeManager {
};
})),
};
if (hints.length > 0) result.hints = hints;
return result;
}
case 'get_media': {
@@ -1645,6 +1689,10 @@ export class OpenCodeManager {
}
case 'list_media': {
if (args.month !== undefined && args.year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const hasMediaFilter = args.year !== undefined || (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0);
let mediaList: MediaData[];
@@ -1877,6 +1925,34 @@ export class OpenCodeManager {
}
}
/** Build ambiguity hint strings when category/tag terms overlap across namespaces. */
private async buildAmbiguityHints(
category: string | undefined,
tags: string[] | undefined,
): Promise<string[]> {
const hints: string[] = [];
if (category) {
const allTags = await this.postEngine.getTagsWithCounts();
const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase());
if (tagMatch) {
hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`);
}
}
if (tags && tags.length > 0) {
const allCats = await this.postEngine.getCategoriesWithCounts();
for (const tag of tags) {
const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase());
if (catMatch) {
hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`);
}
}
}
return hints;
}
/**
* Estimate token count for a string using a rough character heuristic.
* ~3.5 characters per token for English text (conservative, tends to overestimate).