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

@@ -233,7 +233,7 @@
"dependencies": [
"3"
],
"status": "pending",
"status": "done",
"subtasks": [
{
"id": 1,
@@ -294,7 +294,8 @@
"testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks.",
"parentId": "undefined"
}
]
],
"updatedAt": "2026-02-21T04:26:06.239Z"
},
{
"id": "5",
@@ -731,9 +732,9 @@
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-02-21T04:21:50.389Z",
"lastModified": "2026-02-21T04:26:06.239Z",
"taskCount": 24,
"completedCount": 4,
"completedCount": 5,
"tags": [
"master"
]

3
pnpm-lock.yaml generated
View File

@@ -106,6 +106,9 @@ importers:
'@mcpctl/shared':
specifier: workspace:*
version: link:../shared
'@prisma/client':
specifier: ^6.0.0
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
fastify:
specifier: ^5.0.0
version: 5.7.4

View File

@@ -19,6 +19,7 @@
"@fastify/rate-limit": "^10.0.0",
"@mcpctl/db": "workspace:*",
"@mcpctl/shared": "workspace:*",
"@prisma/client": "^6.0.0",
"fastify": "^5.0.0",
"zod": "^3.24.0"
},

View File

@@ -41,7 +41,7 @@ export function errorHandler(
return;
}
// Known HTTP errors
// Known HTTP errors (includes service errors like NotFoundError, ConflictError)
const statusCode = error.statusCode ?? 500;
if (statusCode < 500) {
reply.code(statusCode).send({

View File

@@ -0,0 +1,3 @@
export type { IMcpServerRepository, IMcpProfileRepository } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js';

View File

@@ -0,0 +1,21 @@
import type { McpServer, McpProfile } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
export interface IMcpServerRepository {
findAll(): Promise<McpServer[]>;
findById(id: string): Promise<McpServer | null>;
findByName(name: string): Promise<McpServer | null>;
create(data: CreateMcpServerInput): Promise<McpServer>;
update(id: string, data: UpdateMcpServerInput): Promise<McpServer>;
delete(id: string): Promise<void>;
}
export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,46 @@
import type { PrismaClient, McpProfile } from '@prisma/client';
import type { IMcpProfileRepository } from './interfaces.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
export class McpProfileRepository implements IMcpProfileRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(serverId?: string): Promise<McpProfile[]> {
const where = serverId !== undefined ? { serverId } : {};
return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({ where: { id } });
}
async findByServerAndName(serverId: string, name: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({
where: { name_serverId: { name, serverId } },
});
}
async create(data: CreateMcpProfileInput): Promise<McpProfile> {
return this.prisma.mcpProfile.create({
data: {
name: data.name,
serverId: data.serverId,
permissions: data.permissions,
envOverrides: data.envOverrides,
},
});
}
async update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile> {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.permissions !== undefined) updateData['permissions'] = data.permissions;
if (data.envOverrides !== undefined) updateData['envOverrides'] = data.envOverrides;
return this.prisma.mcpProfile.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.mcpProfile.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,49 @@
import type { PrismaClient, McpServer } from '@prisma/client';
import type { IMcpServerRepository } from './interfaces.js';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
export class McpServerRepository implements IMcpServerRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<McpServer[]> {
return this.prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpServer | null> {
return this.prisma.mcpServer.findUnique({ where: { id } });
}
async findByName(name: string): Promise<McpServer | null> {
return this.prisma.mcpServer.findUnique({ where: { name } });
}
async create(data: CreateMcpServerInput): Promise<McpServer> {
return this.prisma.mcpServer.create({
data: {
name: data.name,
description: data.description,
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate,
},
});
}
async update(id: string, data: UpdateMcpServerInput): Promise<McpServer> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.packageName !== undefined) updateData['packageName'] = data.packageName;
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
if (data.transport !== undefined) updateData['transport'] = data.transport;
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.mcpServer.delete({ where: { id } });
}
}

View File

@@ -1,2 +1,4 @@
export { registerHealthRoutes } from './health.js';
export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js';

View File

@@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
import type { McpProfileService } from '../services/mcp-profile.service.js';
export function registerMcpProfileRoutes(app: FastifyInstance, service: McpProfileService): void {
app.get<{ Querystring: { serverId?: string } }>('/api/v1/profiles', async (request) => {
return service.list(request.query.serverId);
});
app.get<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/profiles', async (request, reply) => {
const profile = await service.create(request.body);
reply.code(201);
return profile;
});
app.put<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
import type { McpServerService } from '../services/mcp-server.service.js';
export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void {
app.get('/api/v1/servers', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/servers', async (request, reply) => {
const server = await service.create(request.body);
reply.code(201);
return server;
});
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,2 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
export { McpProfileService } from './mcp-profile.service.js';

View File

@@ -0,0 +1,62 @@
import type { McpProfile } from '@prisma/client';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateMcpProfileSchema, UpdateMcpProfileSchema } from '../validation/mcp-profile.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class McpProfileService {
constructor(
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(serverId?: string): Promise<McpProfile[]> {
return this.profileRepo.findAll(serverId);
}
async getById(id: string): Promise<McpProfile> {
const profile = await this.profileRepo.findById(id);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${id}`);
}
return profile;
}
async create(input: unknown): Promise<McpProfile> {
const data = CreateMcpProfileSchema.parse(input);
// Verify server exists
const server = await this.serverRepo.findById(data.serverId);
if (server === null) {
throw new NotFoundError(`Server not found: ${data.serverId}`);
}
// Check unique name per server
const existing = await this.profileRepo.findByServerAndName(data.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for server "${server.name}"`);
}
return this.profileRepo.create(data);
}
async update(id: string, input: unknown): Promise<McpProfile> {
const data = UpdateMcpProfileSchema.parse(input);
const profile = await this.getById(id);
// If renaming, check uniqueness
if (data.name !== undefined && data.name !== profile.name) {
const existing = await this.profileRepo.findByServerAndName(profile.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for this server`);
}
}
return this.profileRepo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.profileRepo.delete(id);
}
}

View File

@@ -0,0 +1,69 @@
import type { McpServer } from '@prisma/client';
import type { IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
export class McpServerService {
constructor(private readonly repo: IMcpServerRepository) {}
async list(): Promise<McpServer[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<McpServer> {
const server = await this.repo.findById(id);
if (server === null) {
throw new NotFoundError(`Server not found: ${id}`);
}
return server;
}
async getByName(name: string): Promise<McpServer> {
const server = await this.repo.findByName(name);
if (server === null) {
throw new NotFoundError(`Server not found: ${name}`);
}
return server;
}
async create(input: unknown): Promise<McpServer> {
const data = CreateMcpServerSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Server already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<McpServer> {
const data = UpdateMcpServerSchema.parse(input);
// Verify exists
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
await this.repo.delete(id);
}
}
export class NotFoundError extends Error {
readonly statusCode = 404;
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
export class ConflictError extends Error {
readonly statusCode = 409;
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}

View File

@@ -0,0 +1,4 @@
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js';
export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js';

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const CreateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
serverId: z.string().min(1),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
});
export const UpdateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
permissions: z.array(z.string()).optional(),
envOverrides: z.record(z.string()).optional(),
});
export type CreateMcpProfileInput = z.infer<typeof CreateMcpProfileSchema>;
export type UpdateMcpProfileInput = z.infer<typeof UpdateMcpProfileSchema>;

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
const EnvTemplateEntrySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).default(''),
isSecret: z.boolean().default(false),
setupUrl: z.string().url().optional(),
});
export const CreateMcpServerSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
packageName: z.string().max(200).optional(),
dockerImage: z.string().max(200).optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
});
export const UpdateMcpServerSchema = z.object({
description: z.string().max(1000).optional(),
packageName: z.string().max(200).nullable().optional(),
dockerImage: z.string().max(200).nullable().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
repositoryUrl: z.string().url().nullable().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
});
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
export type UpdateMcpServerInput = z.infer<typeof UpdateMcpServerSchema>;

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);
});
});
});

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);
});
});
});

View 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);
});
});
});

View 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();
});
});