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:
128
src/mcpd/tests/mcp-profile-service.test.ts
Normal file
128
src/mcpd/tests/mcp-profile-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/mcpd/tests/mcp-server-routes.test.ts
Normal file
168
src/mcpd/tests/mcp-server-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
110
src/mcpd/tests/mcp-server-service.test.ts
Normal file
110
src/mcpd/tests/mcp-server-service.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||
|
||||
function mockRepo(): IMcpServerRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
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: data.repositoryUrl ?? null,
|
||||
envTemplate: data.envTemplate ?? [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
update: vi.fn(async (id, data) => ({
|
||||
id,
|
||||
name: 'test',
|
||||
description: (data.description as string) ?? '',
|
||||
packageName: null,
|
||||
dockerImage: null,
|
||||
transport: 'STDIO' as const,
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('McpServerService', () => {
|
||||
let repo: ReturnType<typeof mockRepo>;
|
||||
let service: McpServerService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mockRepo();
|
||||
service = new McpServerService(repo);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all servers', async () => {
|
||||
const servers = await service.list();
|
||||
expect(repo.findAll).toHaveBeenCalled();
|
||||
expect(servers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns server when found', async () => {
|
||||
const server = { id: '1', name: 'test' };
|
||||
vi.mocked(repo.findById).mockResolvedValue(server 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 server with valid input', async () => {
|
||||
const result = await service.create({ name: 'my-server' });
|
||||
expect(result.name).toBe('my-server');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(repo.findByName).mockResolvedValue({ id: '1', name: 'existing' } as never);
|
||||
await expect(service.create({ name: 'existing' })).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('throws on invalid input', async () => {
|
||||
await expect(service.create({ name: '' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates an existing server', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
|
||||
await service.update('1', { description: 'updated' });
|
||||
expect(repo.update).toHaveBeenCalledWith('1', { description: 'updated' });
|
||||
});
|
||||
|
||||
it('throws NotFoundError when server does not exist', async () => {
|
||||
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes an existing server', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
|
||||
await service.delete('1');
|
||||
expect(repo.delete).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when server does not exist', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/mcpd/tests/validation.test.ts
Normal file
124
src/mcpd/tests/validation.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateMcpServerSchema,
|
||||
UpdateMcpServerSchema,
|
||||
CreateMcpProfileSchema,
|
||||
UpdateMcpProfileSchema,
|
||||
} from '../src/validation/index.js';
|
||||
|
||||
describe('CreateMcpServerSchema', () => {
|
||||
it('validates valid input', () => {
|
||||
const result = CreateMcpServerSchema.parse({
|
||||
name: 'my-server',
|
||||
description: 'A test server',
|
||||
transport: 'STDIO',
|
||||
});
|
||||
expect(result.name).toBe('my-server');
|
||||
expect(result.envTemplate).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(() => CreateMcpServerSchema.parse({ name: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects name with spaces', () => {
|
||||
expect(() => CreateMcpServerSchema.parse({ name: 'my server' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects uppercase name', () => {
|
||||
expect(() => CreateMcpServerSchema.parse({ name: 'MyServer' })).toThrow();
|
||||
});
|
||||
|
||||
it('allows hyphens in name', () => {
|
||||
const result = CreateMcpServerSchema.parse({ name: 'my-mcp-server' });
|
||||
expect(result.name).toBe('my-mcp-server');
|
||||
});
|
||||
|
||||
it('defaults transport to STDIO', () => {
|
||||
const result = CreateMcpServerSchema.parse({ name: 'test' });
|
||||
expect(result.transport).toBe('STDIO');
|
||||
});
|
||||
|
||||
it('validates envTemplate entries', () => {
|
||||
const result = CreateMcpServerSchema.parse({
|
||||
name: 'test',
|
||||
envTemplate: [
|
||||
{ name: 'API_KEY', description: 'The key', isSecret: true },
|
||||
],
|
||||
});
|
||||
expect(result.envTemplate).toHaveLength(1);
|
||||
expect(result.envTemplate[0]?.isSecret).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid transport', () => {
|
||||
expect(() => CreateMcpServerSchema.parse({ name: 'test', transport: 'HTTP' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid repository URL', () => {
|
||||
expect(() => CreateMcpServerSchema.parse({ name: 'test', repositoryUrl: 'not-a-url' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateMcpServerSchema', () => {
|
||||
it('allows partial updates', () => {
|
||||
const result = UpdateMcpServerSchema.parse({ description: 'updated' });
|
||||
expect(result.description).toBe('updated');
|
||||
expect(result.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows empty object', () => {
|
||||
const result = UpdateMcpServerSchema.parse({});
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows nullable fields', () => {
|
||||
const result = UpdateMcpServerSchema.parse({ packageName: null, dockerImage: null });
|
||||
expect(result.packageName).toBeNull();
|
||||
expect(result.dockerImage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateMcpProfileSchema', () => {
|
||||
it('validates valid input', () => {
|
||||
const result = CreateMcpProfileSchema.parse({
|
||||
name: 'readonly',
|
||||
serverId: 'server-123',
|
||||
});
|
||||
expect(result.name).toBe('readonly');
|
||||
expect(result.permissions).toEqual([]);
|
||||
expect(result.envOverrides).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(() => CreateMcpProfileSchema.parse({ name: '', serverId: 'x' })).toThrow();
|
||||
});
|
||||
|
||||
it('accepts permissions array', () => {
|
||||
const result = CreateMcpProfileSchema.parse({
|
||||
name: 'admin',
|
||||
serverId: 'x',
|
||||
permissions: ['read', 'write', 'delete'],
|
||||
});
|
||||
expect(result.permissions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('accepts envOverrides', () => {
|
||||
const result = CreateMcpProfileSchema.parse({
|
||||
name: 'staging',
|
||||
serverId: 'x',
|
||||
envOverrides: { API_URL: 'https://staging.example.com' },
|
||||
});
|
||||
expect(result.envOverrides['API_URL']).toBe('https://staging.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateMcpProfileSchema', () => {
|
||||
it('allows partial updates', () => {
|
||||
const result = UpdateMcpProfileSchema.parse({ permissions: ['read'] });
|
||||
expect(result.permissions).toEqual(['read']);
|
||||
});
|
||||
|
||||
it('allows empty object', () => {
|
||||
expect(UpdateMcpProfileSchema.parse({})).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user