feat: mcp server implementation third round

This commit is contained in:
2026-02-28 09:53:45 +01:00
parent 9efe007791
commit e5463b10f9
5 changed files with 188 additions and 56 deletions

View File

@@ -109,10 +109,53 @@ describe('MCPServer integration', () => {
method: 'OPTIONS',
});
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Origin')).toBeTruthy();
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
});
it('rejects requests from non-local origins', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://evil-site.com',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } },
}),
});
expect(response.status).toBe(403);
});
it('allows requests from localhost origins', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'Origin': `http://localhost:${port}`,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } },
}),
});
expect(response.status).not.toBe(403);
});
it('allows requests without Origin header', async () => {
const port = await server.start(0);
const result = await initializeSession(port) as { result?: { serverInfo?: { name: string } } };
expect(result.result?.serverInfo?.name).toBe('Blogging Desktop Server');
});
it('lists tools via tools/list after initialize', async () => {
const port = await server.start(0);

View File

@@ -446,6 +446,38 @@ describe('MCPServer', () => {
});
});
// ── Tool visibility ─────────────────────────────────────────────────
describe('tool visibility', () => {
function getToolMeta(toolName: string): Record<string, unknown> | undefined {
const mcpServer = server.createMcpServer();
const tool = (mcpServer as Record<string, Record<string, { _meta?: Record<string, unknown> }>>)._registeredTools[toolName];
return tool?._meta;
}
it('accept_proposal has app-only visibility', () => {
const meta = getToolMeta('accept_proposal');
expect(meta).toBeDefined();
const ui = (meta as Record<string, unknown>).ui as Record<string, unknown>;
expect(ui?.visibility).toEqual(['app']);
});
it('discard_proposal has app-only visibility', () => {
const meta = getToolMeta('discard_proposal');
expect(meta).toBeDefined();
const ui = (meta as Record<string, unknown>).ui as Record<string, unknown>;
expect(ui?.visibility).toEqual(['app']);
});
it('draft_post has model+app visibility (default)', () => {
const meta = getToolMeta('draft_post');
expect(meta).toBeDefined();
const ui = (meta as Record<string, unknown>).ui as Record<string, unknown>;
// no explicit visibility = default ["model", "app"]
expect(ui?.visibility).toBeUndefined();
});
});
// ── Resource handler behavior ───────────────────────────────────────
describe('resource handlers', () => {
@@ -543,6 +575,28 @@ describe('MCPServer', () => {
expect(JSON.parse(result.content[0].text)).toEqual(searchResults);
});
it('search_posts with query applies offset and limit', async () => {
const searchResults = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}`, slug: `post-${i}` }));
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(3);
expect(parsed[0].id).toBe('p2');
expect(parsed[2].id).toBe('p4');
});
it('search_posts defaults to limit 50 when not specified', async () => {
const searchResults = Array.from({ length: 60 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}`, slug: `post-${i}` }));
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(50);
});
it('search_posts with filters only calls getPostsFiltered', async () => {
const filtered = [{ id: 'p2', title: 'Filtered' }];
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
@@ -553,6 +607,17 @@ describe('MCPServer', () => {
expect(JSON.parse(result.content[0].text)).toEqual(filtered);
});
it('search_posts with filters applies offset and limit', async () => {
const filtered = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}` }));
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(2);
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: '' },