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,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpProfileService } from '../src/services/mcp-profile.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
serverId: data.serverId,
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: data.name ?? 'test',
serverId: 'srv-1',
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('McpProfileService', () => {
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: McpProfileService;
beforeEach(() => {
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new McpProfileService(profileRepo, serverRepo);
});
describe('list', () => {
it('returns all profiles', async () => {
await service.list();
expect(profileRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(profileRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('returns profile when found', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
const result = await service.getById('1');
expect(result.id).toBe('1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a profile when server exists', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
const result = await service.create({ name: 'readonly', serverId: 'srv-1' });
expect(result.name).toBe('readonly');
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.create({ name: 'test', serverId: 'missing' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when profile name exists for server', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '1' } as never);
await expect(service.create({ name: 'dup', serverId: 'srv-1' })).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('updates an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
await service.update('1', { permissions: ['read'] });
expect(profileRepo.update).toHaveBeenCalled();
});
it('checks uniqueness when renaming', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '2' } as never);
await expect(service.update('1', { name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1' } as never);
await service.delete('1');
expect(profileRepo.delete).toHaveBeenCalledWith('1');
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});