feat: more on mcp server

This commit is contained in:
2026-02-28 11:05:39 +01:00
parent 591caf8733
commit e71e478776
5 changed files with 437 additions and 54 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer';
import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer';
// Mock all engine singletons
vi.mock('../../src/main/engine/PostEngine', () => ({
@@ -65,6 +65,7 @@ function createMockScriptEngine() {
entrypoint: 'main.py', content: '', enabled: true, version: 1,
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
}),
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
};
}
@@ -75,6 +76,7 @@ function createMockTemplateEngine() {
enabled: true, version: 1, filePath: '/test', content: '',
createdAt: new Date(), updatedAt: new Date(),
}),
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
};
}
@@ -270,6 +272,34 @@ describe('MCPServer', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true);
});
it('registers posts-page resource template for pagination', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'posts-page')).toBe(true);
});
it('registers media-page resource template for pagination', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-page')).toBe(true);
});
});
describe('cursor encoding', () => {
it('round-trips offset through encode/decode', () => {
expect(decodeCursor(encodeCursor(0))).toBe(0);
expect(decodeCursor(encodeCursor(50))).toBe(50);
expect(decodeCursor(encodeCursor(999))).toBe(999);
});
it('returns 0 for invalid cursor', () => {
expect(decodeCursor('!!!invalid!!!')).toBe(0);
expect(decodeCursor('')).toBe(0);
});
it('returns 0 for negative offset', () => {
// Encoding a negative offset then decoding should clamp to 0
expect(decodeCursor(encodeCursor(-5))).toBe(0);
});
});
describe('registered prompts', () => {
@@ -490,16 +520,71 @@ describe('MCPServer', () => {
return (mcpServer as Record<string, Record<string, { readCallback: (...args: unknown[]) => Promise<unknown> }>>)._registeredResourceTemplates[name];
}
it('bds://posts calls getAllPosts and returns JSON', async () => {
it('bds://posts calls getAllPosts with pagination limit and returns JSON', async () => {
const postsData = { items: [{ id: 'p1', title: 'Hello' }], hasMore: false, total: 1 };
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://posts');
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
expect(mockPostEngine.getAllPosts).toHaveBeenCalled();
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE });
expect(JSON.parse(result.contents[0].text)).toEqual(postsData);
});
it('bds://posts includes nextCursor when hasMore is true', async () => {
const postsData = {
items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${i}` })),
hasMore: true,
total: 120,
};
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://posts');
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.nextCursor).toBeTruthy();
expect(parsed.hasMore).toBe(true);
expect(parsed.total).toBe(120);
// Cursor should decode to the next offset
expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE);
});
it('bds://posts omits nextCursor when hasMore is false', async () => {
const postsData = { items: [{ id: 'p1' }], hasMore: false, total: 1 };
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://posts');
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.nextCursor).toBeUndefined();
});
it('bds://media returns paginated response with items, total, hasMore', async () => {
const allMedia = Array.from({ length: 3 }, (_, i) => ({ id: `m${i}` }));
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://media');
const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.items).toHaveLength(3);
expect(parsed.total).toBe(3);
expect(parsed.hasMore).toBe(false);
expect(parsed.nextCursor).toBeUndefined();
});
it('bds://media includes nextCursor when more items than page size', async () => {
const allMedia = Array.from({ length: DEFAULT_PAGE_SIZE + 10 }, (_, i) => ({ id: `m${i}` }));
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
const mcpServer = server.createMcpServer();
const resource = getResource(mcpServer, 'bds://media');
const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE);
expect(parsed.total).toBe(DEFAULT_PAGE_SIZE + 10);
expect(parsed.hasMore).toBe(true);
expect(parsed.nextCursor).toBeTruthy();
expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE);
});
it('bds://stats calls getBlogStats and returns JSON', async () => {
const stats = { totalPosts: 42 };
mockPostEngine.getBlogStats.mockResolvedValue(stats);
@@ -519,6 +604,61 @@ describe('MCPServer', () => {
expect(JSON.parse(result.contents[0].text)).toEqual(post);
});
it('posts-page template decodes cursor and passes offset to getAllPosts', async () => {
const cursor = encodeCursor(50);
mockPostEngine.getAllPosts.mockResolvedValue({ items: [{ id: 'p50' }], hasMore: false, total: 60 });
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'posts-page');
const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE, offset: 50 });
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.items).toEqual([{ id: 'p50' }]);
expect(parsed.nextCursor).toBeUndefined();
});
it('posts-page template includes nextCursor for subsequent pages', async () => {
const cursor = encodeCursor(50);
mockPostEngine.getAllPosts.mockResolvedValue({
items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${50 + i}` })),
hasMore: true,
total: 200,
});
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'posts-page');
const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.nextCursor).toBeTruthy();
expect(decodeCursor(parsed.nextCursor)).toBe(100);
});
it('media-page template decodes cursor and returns correct slice', async () => {
const allMedia = Array.from({ length: 80 }, (_, i) => ({ id: `m${i}` }));
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
const cursor = encodeCursor(50);
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'media-page');
const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.items).toHaveLength(30);
expect(parsed.items[0].id).toBe('m50');
expect(parsed.total).toBe(80);
expect(parsed.hasMore).toBe(false);
expect(parsed.nextCursor).toBeUndefined();
});
it('media-page template includes nextCursor when more pages remain', async () => {
const allMedia = Array.from({ length: 120 }, (_, i) => ({ id: `m${i}` }));
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
const cursor = encodeCursor(0);
const mcpServer = server.createMcpServer();
const tpl = getResourceTemplate(mcpServer, 'media-page');
const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE);
expect(parsed.hasMore).toBe(true);
expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE);
});
it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => {
const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }];
mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia);
@@ -693,6 +833,50 @@ describe('MCPServer', () => {
expect(proposal!.data.content).toBe('print("hi")');
});
it('propose_script calls validateScript and includes validation result in preview', async () => {
mockScriptEngine.validateScript.mockResolvedValue({ valid: true, errors: [] });
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_script');
const result = await tool.handler({ title: 'Valid Script', kind: 'macro', content: 'print("hello")' }, {}) as { content: Array<{ text: string }> };
expect(mockScriptEngine.validateScript).toHaveBeenCalledWith('print("hello")');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.preview.syntaxValid).toBe(true);
expect(parsed.preview.syntaxErrors).toEqual([]);
});
it('propose_script includes syntax errors in preview when validation fails', async () => {
mockScriptEngine.validateScript.mockResolvedValue({ valid: false, errors: ['invalid syntax (line 1, col 6)'] });
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_script');
const result = await tool.handler({ title: 'Bad Script', kind: 'macro', content: 'def (' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.proposalId).toBeTruthy();
expect(parsed.preview.syntaxValid).toBe(false);
expect(parsed.preview.syntaxErrors).toEqual(['invalid syntax (line 1, col 6)']);
});
it('propose_template calls validateTemplate and includes validation result in preview', async () => {
mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: true, errors: [] });
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_template');
const result = await tool.handler({ title: 'Valid Template', kind: 'post', content: '<h1>{{ title }}</h1>' }, {}) as { content: Array<{ text: string }> };
expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('<h1>{{ title }}</h1>');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.preview.syntaxValid).toBe(true);
expect(parsed.preview.syntaxErrors).toEqual([]);
});
it('propose_template includes syntax errors in preview when validation fails', async () => {
mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: false, errors: ['tag "{% invalid" not closed'] });
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'propose_template');
const result = await tool.handler({ title: 'Bad Template', kind: 'post', content: '{% invalid' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.proposalId).toBeTruthy();
expect(parsed.preview.syntaxValid).toBe(false);
expect(parsed.preview.syntaxErrors).toEqual(['tag "{% invalid" not closed']);
});
it('propose_media_metadata loads current media and stores proposal', async () => {
const currentMedia = { id: 'img-1', alt: 'Old alt', title: 'Old title' };
mockMediaEngine.getMedia.mockResolvedValue(currentMedia);