import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createPlumeServer } from '../../src/server.js';
import { VergeApiClient } from '../../src/client/api.js';
import type { Blog, Article } from '../../src/client/types.js';
describe('MCP Resources', () => {
let client: Client;
let server: ReturnType<typeof createPlumeServer>;
let mockApiClient: Partial<VergeApiClient>;
const mockBlogs: Blog[] = [
{
id: 1,
user_id: 1,
name: 'tech-blog',
slug: 'tech-blog',
description: 'A blog about technology',
created_at: '2024-01-01T00:00:00Z',
deployment_project_name: null,
deployment_url: null,
last_deployed_at: null,
deployment_in_progress: 0,
},
{
id: 2,
user_id: 1,
name: 'personal-blog',
slug: 'personal-blog',
description: 'My personal thoughts',
created_at: '2024-01-02T00:00:00Z',
deployment_project_name: null,
deployment_url: null,
last_deployed_at: null,
deployment_in_progress: 0,
},
];
const mockArticles: Article[] = [
{
id: 101,
blog_id: 1,
title: 'First Article',
content: 'Article content',
slug: 'first-article',
status: 'published',
featured_image: null,
author_id: 1,
excerpt: null,
published_at: '2024-01-15T00:00:00Z',
created_at: '2024-01-10T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z',
},
{
id: 102,
blog_id: 1,
title: 'Second Article',
content: 'Article content 2',
slug: 'second-article',
status: 'draft',
featured_image: null,
author_id: 1,
excerpt: null,
published_at: null,
created_at: '2024-01-12T00:00:00Z',
updated_at: '2024-01-12T00:00:00Z',
},
];
beforeEach(async () => {
// APIクライアントのモックを作成
mockApiClient = {
listBlogs: vi.fn().mockResolvedValue(mockBlogs),
listArticles: vi.fn().mockResolvedValue(mockArticles),
};
// サーバーインスタンスを作成
server = createPlumeServer(mockApiClient as VergeApiClient);
// InMemoryTransportでクライアント/サーバーを接続
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
// クライアントを作成・接続
client = new Client(
{
name: 'test-client',
version: '1.0.0',
},
{
capabilities: {},
}
);
await client.connect(clientTransport);
});
afterEach(async () => {
await client.close();
await server.close();
});
describe('resources/list', () => {
it('ブログリストリソースとテンプレートを返す', async () => {
const response = await client.listResources();
expect(response).toHaveProperty('resources');
expect(response).toHaveProperty('resourceTemplates');
const resources = response.resources;
expect(resources).toHaveLength(1);
expect(resources[0]).toMatchObject({
uri: 'blogs://list',
name: 'Blog List',
mimeType: 'application/json',
});
const templates = response.resourceTemplates;
expect(templates).toHaveLength(1);
expect(templates[0]).toMatchObject({
uriTemplate: 'articles://blog/{blogId}',
name: 'Articles in Blog',
mimeType: 'application/json',
});
});
});
describe('resources/read', () => {
it('blogs://list - ブログ一覧を返す', async () => {
const response = await client.readResource({ uri: 'blogs://list' });
expect(response).toHaveProperty('contents');
const contents = response.contents;
expect(contents).toHaveLength(1);
const content = contents[0];
expect(content.uri).toBe('blogs://list');
expect(content.mimeType).toBe('application/json');
const data = JSON.parse(content.text);
expect(data.total).toBe(2);
expect(data.blogs).toHaveLength(2);
expect(data.blogs[0]).toMatchObject({
id: 1,
name: 'tech-blog',
description: 'A blog about technology',
created_at: '2024-01-01T00:00:00Z',
});
});
it('blogs://list - 20件を超える場合は最初の20件のみ返す', async () => {
// 30件のブログを作成
const manyBlogs = Array.from({ length: 30 }, (_, i) => ({
...mockBlogs[0],
id: i + 1,
name: `blog-${i + 1}`,
title: `Blog ${i + 1}`,
}));
mockApiClient.listBlogs = vi.fn().mockResolvedValue(manyBlogs);
const response = await client.readResource({ uri: 'blogs://list' });
const contents = response.contents;
const data = JSON.parse(contents[0].text);
expect(data.total).toBe(30);
expect(data.blogs).toHaveLength(20);
});
it('articles://blog/{blogId} - 記事一覧を返す', async () => {
const response = await client.readResource({ uri: 'articles://blog/1' });
expect(mockApiClient.listArticles).toHaveBeenCalledWith(1);
const contents = response.contents;
expect(contents).toHaveLength(1);
const content = contents[0];
expect(content.uri).toBe('articles://blog/1');
expect(content.mimeType).toBe('application/json');
const data = JSON.parse(content.text);
expect(data.blogId).toBe(1);
expect(data.total).toBe(2);
expect(data.articles).toHaveLength(2);
expect(data.articles[0]).toMatchObject({
id: 101,
title: 'First Article',
status: 'published',
published_at: '2024-01-15T00:00:00Z',
});
});
it('articles://blog/{blogId} - 20件を超える場合は最初の20件のみ返す', async () => {
// 25件の記事を作成
const manyArticles = Array.from({ length: 25 }, (_, i) => ({
...mockArticles[0],
id: i + 1,
title: `Article ${i + 1}`,
slug: `article-${i + 1}`,
}));
mockApiClient.listArticles = vi.fn().mockResolvedValue(manyArticles);
const response = await client.readResource({ uri: 'articles://blog/1' });
const contents = response.contents;
const data = JSON.parse(contents[0].text);
expect(data.total).toBe(25);
expect(data.articles).toHaveLength(20);
});
it('articles://blog/{blogId} - 無効なblogIdの場合はエラーを返す', async () => {
await expect(
client.readResource({ uri: 'articles://blog/invalid' })
).rejects.toThrow();
});
it('未知のリソースURIの場合はエラーを返す', async () => {
await expect(
client.readResource({ uri: 'unknown://resource' })
).rejects.toThrow('Unknown resource URI');
});
it('APIエラーの場合は適切にエラーを返す', async () => {
mockApiClient.listBlogs = vi.fn().mockRejectedValue(new Error('API Error'));
await expect(
client.readResource({ uri: 'blogs://list' })
).rejects.toThrow('Failed to read resource');
});
});
});