feat: implement database schema with Prisma ORM #2
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
3
src/mcpd/src/repositories/index.ts
Normal file
3
src/mcpd/src/repositories/index.ts
Normal 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';
|
||||
21
src/mcpd/src/repositories/interfaces.ts
Normal file
21
src/mcpd/src/repositories/interfaces.ts
Normal 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>;
|
||||
}
|
||||
46
src/mcpd/src/repositories/mcp-profile.repository.ts
Normal file
46
src/mcpd/src/repositories/mcp-profile.repository.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
49
src/mcpd/src/repositories/mcp-server.repository.ts
Normal file
49
src/mcpd/src/repositories/mcp-server.repository.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
27
src/mcpd/src/routes/mcp-profiles.ts
Normal file
27
src/mcpd/src/routes/mcp-profiles.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
27
src/mcpd/src/routes/mcp-servers.ts
Normal file
27
src/mcpd/src/routes/mcp-servers.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
2
src/mcpd/src/services/index.ts
Normal file
2
src/mcpd/src/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
export { McpProfileService } from './mcp-profile.service.js';
|
||||
62
src/mcpd/src/services/mcp-profile.service.ts
Normal file
62
src/mcpd/src/services/mcp-profile.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/mcpd/src/services/mcp-server.service.ts
Normal file
69
src/mcpd/src/services/mcp-server.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
4
src/mcpd/src/validation/index.ts
Normal file
4
src/mcpd/src/validation/index.ts
Normal 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';
|
||||
17
src/mcpd/src/validation/mcp-profile.schema.ts
Normal file
17
src/mcpd/src/validation/mcp-profile.schema.ts
Normal 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>;
|
||||
30
src/mcpd/src/validation/mcp-server.schema.ts
Normal file
30
src/mcpd/src/validation/mcp-server.schema.ts
Normal 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>;
|
||||
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