feat: add MCP server and profile management API

Add validation schemas (Zod), repository pattern with Prisma, service layer
with business logic (NotFoundError, ConflictError), and REST routes for
MCP server and profile CRUD. 86 mcpd tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:26:18 +00:00
parent d2a682a460
commit ae7d79da6f
21 changed files with 899 additions and 5 deletions

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
let app: FastifyInstance;
function mockRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => [
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' },
]),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: 'slack',
description: (data.description as string) ?? 'Slack server',
packageName: null,
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
function createApp(repo: IMcpServerRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new McpServerService(repo);
registerMcpServerRoutes(app, service);
return app.ready();
}
describe('MCP Server Routes', () => {
describe('GET /api/v1/servers', () => {
it('returns server list', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(res.statusCode).toBe(200);
const body = res.json<Array<{ name: string }>>();
expect(body).toHaveLength(1);
expect(body[0]?.name).toBe('slack');
});
});
describe('GET /api/v1/servers/:id', () => {
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/servers/missing' });
expect(res.statusCode).toBe(404);
});
it('returns server when found', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/servers/1' });
expect(res.statusCode).toBe(200);
});
});
describe('POST /api/v1/servers', () => {
it('creates a server and returns 201', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: { name: 'new-server' },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ name: string }>().name).toBe('new-server');
});
it('returns 400 for invalid input', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: { name: '' },
});
expect(res.statusCode).toBe(400);
});
it('returns 409 when name already exists', async () => {
const repo = mockRepo();
vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never);
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: { name: 'existing' },
});
expect(res.statusCode).toBe(409);
});
});
describe('PUT /api/v1/servers/:id', () => {
it('updates a server', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/servers/1',
payload: { description: 'Updated' },
});
expect(res.statusCode).toBe(200);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/servers/missing',
payload: { description: 'x' },
});
expect(res.statusCode).toBe(404);
});
});
describe('DELETE /api/v1/servers/:id', () => {
it('deletes a server and returns 204', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/1' });
expect(res.statusCode).toBe(204);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/missing' });
expect(res.statusCode).toBe(404);
});
});
});