feat: posts also get structured forward links
This commit is contained in:
@@ -307,9 +307,9 @@ You can ONLY access information through the tools listed below. Do not claim oth
|
||||
|
||||
Available Data Tools:
|
||||
- get_blog_stats: Get comprehensive blog statistics (total posts, date range, posts per year, tag/category counts, media count). ALWAYS call this first when you need to understand the scope of the data.
|
||||
- search_posts: Search blog posts using full-text search. Supports category/tag/year/month filters and pagination (offset/limit).
|
||||
- read_post: Read the full content and metadata of a specific post by ID.
|
||||
- list_posts: List posts with optional filtering by status, category, tags, year, and month. Supports pagination (offset/limit). Returns "total" (global count) and "filteredTotal" (matching filter). ALWAYS use the year filter when you need posts from a specific year — this is much faster than paginating through all posts.
|
||||
- search_posts: Search blog posts using full-text search. Supports category/tag/year/month filters and pagination (offset/limit). Results include backlinks and linksTo.
|
||||
- read_post: Read the full content and metadata of a specific post by ID. Includes backlinks and linksTo.
|
||||
- list_posts: List posts with optional filtering by status, category, tags, year, and month. Supports pagination (offset/limit). Returns "total" (global count) and "filteredTotal" (matching filter). Includes backlinks and linksTo. ALWAYS use the year filter when you need posts from a specific year — this is much faster than paginating through all posts.
|
||||
- get_media: Get information about a specific media file by ID.
|
||||
- list_media: List media files with optional MIME type, year, month, and tag filtering. Supports pagination (offset/limit). Use year/month filters to narrow efficiently.
|
||||
- view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like.
|
||||
|
||||
@@ -431,8 +431,15 @@ export class MCPServer {
|
||||
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
||||
const postId = id as string;
|
||||
const result = await this.deps.postEngine.getPost(postId);
|
||||
const backlinks = await this.deps.postEngine.getLinkedBy(postId);
|
||||
const enriched = { ...result, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })) };
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.deps.postEngine.getLinkedBy(postId),
|
||||
this.deps.postEngine.getLinksTo(postId),
|
||||
]);
|
||||
const enriched = {
|
||||
...result,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
|
||||
};
|
||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(enriched) }] };
|
||||
});
|
||||
|
||||
@@ -483,7 +490,7 @@ export class MCPServer {
|
||||
private registerReadTools(server: McpServer): void {
|
||||
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).',
|
||||
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).',
|
||||
inputSchema: {
|
||||
query: z.string().optional().describe('Full-text search query'),
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
@@ -500,11 +507,18 @@ export class MCPServer {
|
||||
const offset = args.offset ?? 0;
|
||||
const limit = args.limit ?? 50;
|
||||
|
||||
// Helper: enrich posts with backlinks
|
||||
const enrichWithBacklinks = async <T extends { id: string }>(posts: T[]) => {
|
||||
// Helper: enrich posts with backlinks and linksTo
|
||||
const enrichWithLinks = async <T extends { id: string }>(posts: T[]) => {
|
||||
return Promise.all(posts.map(async (p) => {
|
||||
const backlinks = await this.deps.postEngine.getLinkedBy(p.id);
|
||||
return { ...p, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })) };
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.deps.postEngine.getLinkedBy(p.id),
|
||||
this.deps.postEngine.getLinksTo(p.id),
|
||||
]);
|
||||
return {
|
||||
...p,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -512,7 +526,7 @@ export class MCPServer {
|
||||
// Pure text search — use FTS
|
||||
const results = await this.deps.postEngine.searchPosts(args.query);
|
||||
const paginated = results.slice(offset, offset + limit);
|
||||
const enriched = await enrichWithBacklinks(paginated);
|
||||
const enriched = await enrichWithLinks(paginated);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||
}
|
||||
|
||||
@@ -529,14 +543,14 @@ export class MCPServer {
|
||||
const results = await this.deps.postEngine.searchPostsFiltered(
|
||||
args.query, filter, { offset, limit },
|
||||
);
|
||||
const enriched = await enrichWithBacklinks(results);
|
||||
const enriched = await enrichWithLinks(results);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||
}
|
||||
|
||||
// Filter-only query (no text search)
|
||||
const results = await this.deps.postEngine.getPostsFiltered(filter);
|
||||
const paginated = results.slice(offset, offset + limit);
|
||||
const enriched = await enrichWithBacklinks(paginated);
|
||||
const enriched = await enrichWithLinks(paginated);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1283,13 +1283,17 @@ export class OpenCodeManager {
|
||||
offset,
|
||||
limit,
|
||||
posts: await Promise.all(filteredPosts.map(async p => {
|
||||
const backlinks = await this.postEngine.getLinkedBy(p.id);
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.postEngine.getLinkedBy(p.id),
|
||||
this.postEngine.getLinksTo(p.id),
|
||||
]);
|
||||
return {
|
||||
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,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
|
||||
};
|
||||
})),
|
||||
};
|
||||
@@ -1298,7 +1302,10 @@ export class OpenCodeManager {
|
||||
case 'read_post': {
|
||||
const post = await this.postEngine.getPost(args.postId as string);
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
const backlinks = await this.postEngine.getLinkedBy(post.id);
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.postEngine.getLinkedBy(post.id),
|
||||
this.postEngine.getLinksTo(post.id),
|
||||
]);
|
||||
return {
|
||||
success: true,
|
||||
post: {
|
||||
@@ -1309,6 +1316,7 @@ export class OpenCodeManager {
|
||||
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1350,12 +1358,16 @@ export class OpenCodeManager {
|
||||
offset,
|
||||
limit,
|
||||
posts: await Promise.all(pageItems.map(async p => {
|
||||
const backlinks = await this.postEngine.getLinkedBy(p.id);
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.postEngine.getLinkedBy(p.id),
|
||||
this.postEngine.getLinksTo(p.id),
|
||||
]);
|
||||
return {
|
||||
id: p.id, title: p.title, slug: p.slug,
|
||||
status: p.status, categories: p.categories,
|
||||
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
|
||||
};
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* OpenCodeManager Tool Execution Tests
|
||||
*
|
||||
* Tests the executeTool method for post-related tools,
|
||||
* specifically that backlinks are included in results.
|
||||
* specifically that backlinks and linksTo are included in results.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
@@ -73,7 +73,7 @@ function createManager(postEngine: ReturnType<typeof createMockPostEngine>, medi
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
describe('OpenCodeManager tool execution – backlinks & linksTo', () => {
|
||||
let mockPostEngine: ReturnType<typeof createMockPostEngine>;
|
||||
let manager: OpenCodeManager;
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
});
|
||||
|
||||
describe('read_post', () => {
|
||||
it('includes backlinks in the response', async () => {
|
||||
it('includes backlinks and linksTo in the response', async () => {
|
||||
const post = {
|
||||
id: 'p1', title: 'Target Post', slug: 'target-post',
|
||||
content: '# Hello', excerpt: 'Hello', status: 'published',
|
||||
@@ -97,6 +97,9 @@ describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
|
||||
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
|
||||
]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([
|
||||
{ id: 'p4', title: 'Linked Target', slug: 'linked-target' },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
|
||||
|
||||
@@ -105,10 +108,14 @@ describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
|
||||
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
|
||||
]);
|
||||
expect(result.post.linksTo).toEqual([
|
||||
{ id: 'p4', title: 'Linked Target', slug: 'linked-target' },
|
||||
]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('returns empty backlinks array when no backlinks exist', async () => {
|
||||
it('returns empty backlinks and linksTo arrays when none exist', async () => {
|
||||
const post = {
|
||||
id: 'p1', title: 'Lonely Post', slug: 'lonely-post',
|
||||
content: '# Alone', excerpt: '', status: 'draft',
|
||||
@@ -117,16 +124,18 @@ describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
};
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.post.backlinks).toEqual([]);
|
||||
expect(result.post.linksTo).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_posts', () => {
|
||||
it('includes backlinks for each post in search results', async () => {
|
||||
it('includes backlinks and linksTo for each post in search results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p1', title: 'Post One', slug: 'post-one', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'p2', title: 'Post Two', slug: 'post-two', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
@@ -135,18 +144,24 @@ describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
mockPostEngine.getLinkedBy
|
||||
.mockResolvedValueOnce([{ id: 'p3', title: 'Linker', slug: 'linker' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockPostEngine.getLinksTo
|
||||
.mockResolvedValueOnce([{ id: 'p4', title: 'Target', slug: 'target' }])
|
||||
.mockResolvedValueOnce([{ id: 'p5', title: 'Other', slug: 'other' }]);
|
||||
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([{ id: 'p3', title: 'Linker', slug: 'linker' }]);
|
||||
expect(result.posts[0].linksTo).toEqual([{ id: 'p4', title: 'Target', slug: 'target' }]);
|
||||
expect(result.posts[1].backlinks).toEqual([]);
|
||||
expect(result.posts[1].linksTo).toEqual([{ id: 'p5', title: 'Other', slug: 'other' }]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledTimes(2);
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_posts', () => {
|
||||
it('includes backlinks for each post in listed results', async () => {
|
||||
it('includes backlinks and linksTo for each post in listed results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p1', title: 'Post A', slug: 'post-a', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
@@ -154,26 +169,34 @@ describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||
{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' },
|
||||
]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([
|
||||
{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' }]);
|
||||
expect(result.posts[0].linksTo).toEqual([{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' }]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('includes backlinks for filtered list results', async () => {
|
||||
it('includes backlinks and linksTo for filtered list results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p5', title: 'Tagged Post', slug: 'tagged', status: 'published', categories: [], tags: ['js'], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue(posts);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', { tags: ['js'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([]);
|
||||
expect(result.posts[0].linksTo).toEqual([]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p5');
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p5');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user