feat: mcp server round four
This commit is contained in:
@@ -29,6 +29,7 @@ interface PostEngineContract {
|
||||
getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
||||
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
searchPosts: (query: string) => Promise<Array<{ id: string; title: string; slug: string; excerpt?: string }>>;
|
||||
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise<Array<Record<string, unknown>>>;
|
||||
createPost: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
updatePost: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
||||
publishPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
@@ -383,26 +384,24 @@ export class MCPServer {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||
}
|
||||
|
||||
// Filter-based query (optionally narrowed by text search)
|
||||
// Build structural filter
|
||||
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 results = await this.deps.getPostEngine().getPostsFiltered(filter);
|
||||
|
||||
// Client-side text filter when query is combined with structured filters
|
||||
if (args.query) {
|
||||
const q = args.query.toLowerCase();
|
||||
results = results.filter((p: Record<string, unknown>) => {
|
||||
const title = String(p.title ?? '').toLowerCase();
|
||||
const content = String(p.content ?? '').toLowerCase();
|
||||
const excerpt = String(p.excerpt ?? '').toLowerCase();
|
||||
return title.includes(q) || content.includes(q) || excerpt.includes(q);
|
||||
});
|
||||
if (args.query && hasFilters) {
|
||||
// FTS + structural filters: single SQL JOIN query, ranked by FTS score
|
||||
const results = await this.deps.getPostEngine().searchPostsFiltered(
|
||||
args.query, filter, { offset, limit },
|
||||
);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
||||
}
|
||||
|
||||
// Filter-only query (no text search)
|
||||
const results = await this.deps.getPostEngine().getPostsFiltered(filter);
|
||||
const paginated = results.slice(offset, offset + limit);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||
});
|
||||
@@ -424,19 +423,26 @@ export class MCPServer {
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-post' } },
|
||||
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
|
||||
const post = await this.deps.getPostEngine().createPost({
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
excerpt: args.excerpt,
|
||||
tags: args.tags ?? [],
|
||||
categories: args.categories ?? [],
|
||||
author: args.author,
|
||||
status: 'draft',
|
||||
});
|
||||
const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record<string, unknown>).id });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
||||
};
|
||||
try {
|
||||
const post = await this.deps.getPostEngine().createPost({
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
excerpt: args.excerpt,
|
||||
tags: args.tags ?? [],
|
||||
categories: args.categories ?? [],
|
||||
author: args.author,
|
||||
status: 'draft',
|
||||
});
|
||||
const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record<string, unknown>).id });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to create draft: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── propose_script ──
|
||||
@@ -499,15 +505,28 @@ export class MCPServer {
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
|
||||
}, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => {
|
||||
const { mediaId, ...changes } = args;
|
||||
const current = await this.deps.getMediaEngine().getMedia(mediaId);
|
||||
const proposalId = this.proposalStore.create('proposeMediaMetadata', {
|
||||
mediaId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
try {
|
||||
const { mediaId, ...changes } = args;
|
||||
const current = await this.deps.getMediaEngine().getMedia(mediaId);
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Media item ${mediaId} not found.` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const proposalId = this.proposalStore.create('proposeMediaMetadata', {
|
||||
mediaId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose media metadata: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── propose_post_metadata ──
|
||||
@@ -524,15 +543,28 @@ export class MCPServer {
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
|
||||
}, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
|
||||
const { postId, ...changes } = args;
|
||||
const current = await this.deps.getPostEngine().getPost(postId);
|
||||
const proposalId = this.proposalStore.create('proposePostMetadata', {
|
||||
postId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
try {
|
||||
const { postId, ...changes } = args;
|
||||
const current = await this.deps.getPostEngine().getPost(postId);
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post ${postId} not found.` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const proposalId = this.proposalStore.create('proposePostMetadata', {
|
||||
postId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose post metadata: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Register ui:// resources for review Views ──
|
||||
|
||||
@@ -1253,46 +1253,47 @@ export class OpenCodeManager {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'search_posts': {
|
||||
const searchResults = await this.postEngine.searchPosts(args.query as string);
|
||||
const fullPosts = await Promise.all(
|
||||
searchResults.map(sr => this.postEngine.getPost(sr.id))
|
||||
);
|
||||
let filteredPosts = fullPosts.filter(p => p !== null);
|
||||
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[];
|
||||
if (args.year !== undefined) filter.year = args.year as number;
|
||||
if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number;
|
||||
|
||||
if (args.category) {
|
||||
filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category as string));
|
||||
}
|
||||
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) {
|
||||
filteredPosts = filteredPosts.filter(p =>
|
||||
(args.tags as string[]).every(tag => p!.tags.includes(tag))
|
||||
const hasFilters = Object.keys(filter).length > 0;
|
||||
const offset = (args.offset as number) || 0;
|
||||
const limit = (args.limit as number) || 10;
|
||||
|
||||
let filteredPosts;
|
||||
if (hasFilters) {
|
||||
// Combined FTS + structural filters in a single SQL query
|
||||
filteredPosts = await this.postEngine.searchPostsFiltered(
|
||||
args.query as string, filter, { offset, limit },
|
||||
);
|
||||
}
|
||||
if (args.year !== undefined) {
|
||||
const year = args.year as number;
|
||||
filteredPosts = filteredPosts.filter(p => p!.createdAt.getFullYear() === year);
|
||||
}
|
||||
if (args.month !== undefined && args.year !== undefined) {
|
||||
const month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
||||
filteredPosts = filteredPosts.filter(p => p!.createdAt.getMonth() === month);
|
||||
} else {
|
||||
// Pure FTS search
|
||||
const searchResults = await this.postEngine.searchPosts(args.query as string);
|
||||
// searchPosts returns sparse results; fetch full post data
|
||||
const fullPosts = await Promise.all(
|
||||
searchResults.map(sr => this.postEngine.getPost(sr.id))
|
||||
);
|
||||
const all = fullPosts.filter(p => p !== null) as PostData[];
|
||||
filteredPosts = all.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
const totalMatches = filteredPosts.length;
|
||||
const offset = (args.offset as number) || 0;
|
||||
const limit = (args.limit as number) || 10;
|
||||
filteredPosts = filteredPosts.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: filteredPosts.length,
|
||||
totalMatches,
|
||||
hasMore: offset + limit < totalMatches,
|
||||
hasMore: false,
|
||||
offset,
|
||||
limit,
|
||||
posts: filteredPosts.map(p => ({
|
||||
id: p!.id, title: p!.title, slug: p!.slug,
|
||||
excerpt: p!.excerpt, status: p!.status,
|
||||
categories: p!.categories, tags: p!.tags,
|
||||
createdAt: p!.createdAt, updatedAt: p!.updatedAt,
|
||||
id: p.id, title: p.title, slug: p.slug,
|
||||
excerpt: p.excerpt, status: p.status,
|
||||
categories: p.categories, tags: p.tags,
|
||||
createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -861,6 +861,107 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined FTS search + structural filters in a single SQL query.
|
||||
* Returns full PostData ordered by FTS rank, filtered by the given criteria.
|
||||
* Both MCP and internal AI tools use this for query+filter combinations.
|
||||
*/
|
||||
async searchPostsFiltered(
|
||||
query: string,
|
||||
filter: PostFilter,
|
||||
pagination?: PaginationOptions,
|
||||
): Promise<PostData[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const stemmedQuery = stemQuery(query, this.searchLanguage);
|
||||
|
||||
// Build WHERE clauses and args for the joined query
|
||||
const conditions: string[] = [
|
||||
'posts_fts.project_id = ?',
|
||||
'posts_fts.MATCH ?',
|
||||
];
|
||||
const args: (string | number | Date)[] = [this.currentProjectId, stemmedQuery];
|
||||
|
||||
if (filter.status) {
|
||||
conditions.push('posts.status = ?');
|
||||
args.push(filter.status);
|
||||
}
|
||||
if (filter.year !== undefined) {
|
||||
const startOfYear = new Date(filter.year, 0, 1);
|
||||
const endOfYear = new Date(filter.year + 1, 0, 1);
|
||||
conditions.push('posts.created_at >= ?');
|
||||
args.push(startOfYear);
|
||||
conditions.push('posts.created_at <= ?');
|
||||
args.push(endOfYear);
|
||||
}
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
const startOfMonth = new Date(filter.year, filter.month - 1, 1);
|
||||
const endOfMonth = new Date(filter.year, filter.month, 1);
|
||||
conditions.push('posts.created_at >= ?');
|
||||
args.push(startOfMonth);
|
||||
conditions.push('posts.created_at <= ?');
|
||||
args.push(endOfMonth);
|
||||
}
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
const catClauses = filter.categories.map(() =>
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
|
||||
);
|
||||
conditions.push(`(${catClauses.join(' OR ')})`);
|
||||
args.push(...filter.categories);
|
||||
}
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
const exClauses = filter.excludeCategories.map(() =>
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
|
||||
);
|
||||
conditions.push(`NOT (${exClauses.join(' OR ')})`);
|
||||
args.push(...filter.excludeCategories);
|
||||
}
|
||||
if (filter.startDate) {
|
||||
conditions.push('posts.created_at >= ?');
|
||||
args.push(filter.startDate);
|
||||
}
|
||||
if (filter.endDate) {
|
||||
conditions.push('posts.created_at <= ?');
|
||||
args.push(filter.endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ');
|
||||
const sqlQuery = `
|
||||
SELECT posts.*
|
||||
FROM posts_fts
|
||||
JOIN posts ON posts_fts.id = posts.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY posts_fts.rank
|
||||
LIMIT 500
|
||||
`;
|
||||
|
||||
const result = await client.execute({ sql: sqlQuery, args });
|
||||
|
||||
let postDataList: PostData[] = result.rows.map((row) =>
|
||||
this.dbRowToPostData(row as unknown as Post, (row.content as string) || '')
|
||||
);
|
||||
|
||||
// Tag filtering is done client-side (tags are stored as JSON arrays)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
postDataList = postDataList.filter((p) =>
|
||||
filter.tags!.every((tag) => p.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const offset = pagination?.offset ?? 0;
|
||||
const limit = pagination?.limit ?? postDataList.length;
|
||||
return postDataList.slice(offset, offset + limit);
|
||||
} catch (error) {
|
||||
console.error('Search with filters failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableTags(): Promise<string[]> {
|
||||
const allPosts = await this.getAllPostsUnpaginated();
|
||||
const tags = new Set<string>();
|
||||
|
||||
Reference in New Issue
Block a user