feat: mcp server round four

This commit is contained in:
2026-02-28 10:18:26 +01:00
parent e5463b10f9
commit 591caf8733
6 changed files with 358 additions and 76 deletions

View File

@@ -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 ──

View File

@@ -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,
})),
};
}

View File

@@ -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>();

View File

@@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies {
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]),
}),
getMediaEngine: () => ({
getAllMedia: vi.fn().mockResolvedValue([]),

View File

@@ -29,6 +29,7 @@ function createMockPostEngine() {
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
getPost: vi.fn().mockResolvedValue(null),
searchPosts: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]),
createPost: vi.fn().mockResolvedValue({
id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft',
tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(),
@@ -618,20 +619,49 @@ describe('MCPServer', () => {
expect(parsed[0].id).toBe('p3');
});
it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => {
const allFiltered = [
{ id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' },
{ id: 'p2', title: 'Python Guide', content: 'Learn Python', excerpt: '' },
it('search_posts with query + filters calls searchPostsFiltered', async () => {
const combined = [
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
];
mockPostEngine.getPostsFiltered.mockResolvedValue(allFiltered);
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalled();
// Should call searchPostsFiltered, not searchPosts + getPostsFiltered separately
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
'typescript',
{ categories: ['tech'] },
{ offset: 0, limit: 50 },
);
expect(mockPostEngine.searchPosts).not.toHaveBeenCalled();
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(1);
expect(parsed[0].title).toBe('TypeScript Guide');
expect(parsed[0].id).toBe('p1');
});
it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([{ id: 'p3', title: 'Result' }]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {});
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
'keyword',
{ status: 'published' },
{ offset: 5, limit: 10 },
);
});
it('search_posts with query + multiple filters builds correct filter', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {});
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
'test',
{ categories: ['tech'], tags: ['js'], year: 2025, status: 'published' },
{ offset: 0, limit: 50 },
);
});
it('draft_post creates a draft and stores proposal', async () => {
@@ -677,6 +707,18 @@ describe('MCPServer', () => {
expect(proposal!.data.mediaId).toBe('img-1');
});
it('propose_media_metadata returns error for non-existent media', async () => {
mockMediaEngine.getMedia.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_media_metadata');
const result = await tool.handler({ mediaId: 'no-such', alt: 'New alt' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('not found');
// No proposal should be created
expect(server.proposalStore.getAll()).toHaveLength(0);
});
it('propose_post_metadata loads current post and stores proposal', async () => {
const currentPost = { id: 'post-1', title: 'Old Title', excerpt: 'Old excerpt' };
mockPostEngine.getPost.mockResolvedValue(currentPost);
@@ -689,6 +731,48 @@ describe('MCPServer', () => {
const proposal = server.proposalStore.get(parsed.proposalId);
expect(proposal!.type).toBe('proposePostMetadata');
});
it('propose_post_metadata returns error for non-existent post', async () => {
mockPostEngine.getPost.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_post_metadata');
const result = await tool.handler({ postId: 'no-such', title: 'New Title' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('not found');
expect(server.proposalStore.getAll()).toHaveLength(0);
});
it('draft_post returns error when createPost fails', async () => {
mockPostEngine.createPost.mockRejectedValue(new Error('No active project'));
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'draft_post');
const result = await tool.handler({ title: 'Test', content: '# Hello' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('No active project');
expect(server.proposalStore.getAll()).toHaveLength(0);
});
it('propose_media_metadata returns error when getMedia throws', async () => {
mockMediaEngine.getMedia.mockRejectedValue(new Error('DB error'));
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_media_metadata');
const result = await tool.handler({ mediaId: 'img-1', alt: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('DB error');
});
it('propose_post_metadata returns error when getPost throws', async () => {
mockPostEngine.getPost.mockRejectedValue(new Error('DB error'));
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_post_metadata');
const result = await tool.handler({ postId: 'p1', title: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('DB error');
});
});
// ── Prompt handler behavior ────────────────────────────────────────

View File

@@ -2418,6 +2418,69 @@ Published snapshot content`);
});
});
describe('searchPostsFiltered', () => {
it('should return empty array for empty query', async () => {
const result = await postEngine.searchPostsFiltered('', {});
expect(result).toEqual([]);
});
it('should use FTS JOIN with posts table to combine search and filters', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ id: 'p1', projectId: 'test-project', title: 'Found', slug: 'found', excerpt: 'Excerpt', content: 'Content', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js"]', categories: '["tech"]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
],
});
const result = await postEngine.searchPostsFiltered('search term', { status: 'published' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('p1');
// Verify SQL includes both MATCH and status filter
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('match');
expect(sql).toContain('status');
expect(sql).toContain('order by');
expect(sql).toContain('rank');
});
it('should apply category filter in SQL', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.searchPostsFiltered('term', { categories: ['tech'] });
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('match');
expect(sql).toContain('json_each');
});
it('should apply tag filter client-side after SQL query', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ id: 'p1', projectId: 'test-project', title: 'Has Tag', slug: 'has-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js", "react"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
{ id: 'p2', projectId: 'test-project', title: 'No Tag', slug: 'no-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
],
});
const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('p1');
});
it('should apply pagination with offset and limit', async () => {
const rows = Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`, projectId: 'test-project', title: `Post ${i}`, slug: `post-${i}`, excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '[]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null,
}));
mockLocalClient.execute.mockResolvedValueOnce({ rows });
const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 });
expect(result).toHaveLength(2);
expect(result[0].id).toBe('p1');
expect(result[1].id).toBe('p2');
});
});
describe('getTagsWithCounts', () => {
it('should return empty array when no posts have tags', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {