From 3fa2bc5ffa853170409f7a81ad083ec5b8edddd7 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 04:26:18 +0000 Subject: [PATCH] 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 --- .taskmaster/tasks/tasks.json | 9 +- pnpm-lock.yaml | 3 + src/mcpd/package.json | 1 + src/mcpd/src/middleware/error-handler.ts | 2 +- src/mcpd/src/repositories/index.ts | 3 + src/mcpd/src/repositories/interfaces.ts | 21 +++ .../repositories/mcp-profile.repository.ts | 46 +++++ .../src/repositories/mcp-server.repository.ts | 49 +++++ src/mcpd/src/routes/index.ts | 2 + src/mcpd/src/routes/mcp-profiles.ts | 27 +++ src/mcpd/src/routes/mcp-servers.ts | 27 +++ src/mcpd/src/services/index.ts | 2 + src/mcpd/src/services/mcp-profile.service.ts | 62 +++++++ src/mcpd/src/services/mcp-server.service.ts | 69 +++++++ src/mcpd/src/validation/index.ts | 4 + src/mcpd/src/validation/mcp-profile.schema.ts | 17 ++ src/mcpd/src/validation/mcp-server.schema.ts | 30 ++++ src/mcpd/tests/mcp-profile-service.test.ts | 128 +++++++++++++ src/mcpd/tests/mcp-server-routes.test.ts | 168 ++++++++++++++++++ src/mcpd/tests/mcp-server-service.test.ts | 110 ++++++++++++ src/mcpd/tests/validation.test.ts | 124 +++++++++++++ 21 files changed, 899 insertions(+), 5 deletions(-) create mode 100644 src/mcpd/src/repositories/index.ts create mode 100644 src/mcpd/src/repositories/interfaces.ts create mode 100644 src/mcpd/src/repositories/mcp-profile.repository.ts create mode 100644 src/mcpd/src/repositories/mcp-server.repository.ts create mode 100644 src/mcpd/src/routes/mcp-profiles.ts create mode 100644 src/mcpd/src/routes/mcp-servers.ts create mode 100644 src/mcpd/src/services/index.ts create mode 100644 src/mcpd/src/services/mcp-profile.service.ts create mode 100644 src/mcpd/src/services/mcp-server.service.ts create mode 100644 src/mcpd/src/validation/index.ts create mode 100644 src/mcpd/src/validation/mcp-profile.schema.ts create mode 100644 src/mcpd/src/validation/mcp-server.schema.ts create mode 100644 src/mcpd/tests/mcp-profile-service.test.ts create mode 100644 src/mcpd/tests/mcp-server-routes.test.ts create mode 100644 src/mcpd/tests/mcp-server-service.test.ts create mode 100644 src/mcpd/tests/validation.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index dda0960..1c40700 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38aa6b7..c4cc270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,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 diff --git a/src/mcpd/package.json b/src/mcpd/package.json index feec6e0..0635ea7 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -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" }, diff --git a/src/mcpd/src/middleware/error-handler.ts b/src/mcpd/src/middleware/error-handler.ts index 59fdb19..7d06cd3 100644 --- a/src/mcpd/src/middleware/error-handler.ts +++ b/src/mcpd/src/middleware/error-handler.ts @@ -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({ diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts new file mode 100644 index 0000000..6c2a23b --- /dev/null +++ b/src/mcpd/src/repositories/index.ts @@ -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'; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts new file mode 100644 index 0000000..ff71f23 --- /dev/null +++ b/src/mcpd/src/repositories/interfaces.ts @@ -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; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: CreateMcpServerInput): Promise; + update(id: string, data: UpdateMcpServerInput): Promise; + delete(id: string): Promise; +} + +export interface IMcpProfileRepository { + findAll(serverId?: string): Promise; + findById(id: string): Promise; + findByServerAndName(serverId: string, name: string): Promise; + create(data: CreateMcpProfileInput): Promise; + update(id: string, data: UpdateMcpProfileInput): Promise; + delete(id: string): Promise; +} diff --git a/src/mcpd/src/repositories/mcp-profile.repository.ts b/src/mcpd/src/repositories/mcp-profile.repository.ts new file mode 100644 index 0000000..7128091 --- /dev/null +++ b/src/mcpd/src/repositories/mcp-profile.repository.ts @@ -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 { + const where = serverId !== undefined ? { serverId } : {}; + return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.mcpProfile.findUnique({ where: { id } }); + } + + async findByServerAndName(serverId: string, name: string): Promise { + return this.prisma.mcpProfile.findUnique({ + where: { name_serverId: { name, serverId } }, + }); + } + + async create(data: CreateMcpProfileInput): Promise { + 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 { + const updateData: Record = {}; + 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 { + await this.prisma.mcpProfile.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts new file mode 100644 index 0000000..92a031a --- /dev/null +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -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 { + return this.prisma.mcpServer.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.mcpServer.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.mcpServer.findUnique({ where: { name } }); + } + + async create(data: CreateMcpServerInput): Promise { + 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 { + const updateData: Record = {}; + 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 { + await this.prisma.mcpServer.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index 9f2c77a..79e2180 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -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'; diff --git a/src/mcpd/src/routes/mcp-profiles.ts b/src/mcpd/src/routes/mcp-profiles.ts new file mode 100644 index 0000000..c710375 --- /dev/null +++ b/src/mcpd/src/routes/mcp-profiles.ts @@ -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); + }); +} diff --git a/src/mcpd/src/routes/mcp-servers.ts b/src/mcpd/src/routes/mcp-servers.ts new file mode 100644 index 0000000..fe6273c --- /dev/null +++ b/src/mcpd/src/routes/mcp-servers.ts @@ -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); + }); +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts new file mode 100644 index 0000000..ab36547 --- /dev/null +++ b/src/mcpd/src/services/index.ts @@ -0,0 +1,2 @@ +export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js'; +export { McpProfileService } from './mcp-profile.service.js'; diff --git a/src/mcpd/src/services/mcp-profile.service.ts b/src/mcpd/src/services/mcp-profile.service.ts new file mode 100644 index 0000000..c2a33df --- /dev/null +++ b/src/mcpd/src/services/mcp-profile.service.ts @@ -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 { + return this.profileRepo.findAll(serverId); + } + + async getById(id: string): Promise { + const profile = await this.profileRepo.findById(id); + if (profile === null) { + throw new NotFoundError(`Profile not found: ${id}`); + } + return profile; + } + + async create(input: unknown): Promise { + 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 { + 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 { + await this.getById(id); + await this.profileRepo.delete(id); + } +} diff --git a/src/mcpd/src/services/mcp-server.service.ts b/src/mcpd/src/services/mcp-server.service.ts new file mode 100644 index 0000000..4640dda --- /dev/null +++ b/src/mcpd/src/services/mcp-server.service.ts @@ -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 { + return this.repo.findAll(); + } + + async getById(id: string): Promise { + const server = await this.repo.findById(id); + if (server === null) { + throw new NotFoundError(`Server not found: ${id}`); + } + return server; + } + + async getByName(name: string): Promise { + const server = await this.repo.findByName(name); + if (server === null) { + throw new NotFoundError(`Server not found: ${name}`); + } + return server; + } + + async create(input: unknown): Promise { + 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 { + const data = UpdateMcpServerSchema.parse(input); + + // Verify exists + await this.getById(id); + + return this.repo.update(id, data); + } + + async delete(id: string): Promise { + // 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'; + } +} diff --git a/src/mcpd/src/validation/index.ts b/src/mcpd/src/validation/index.ts new file mode 100644 index 0000000..7758e07 --- /dev/null +++ b/src/mcpd/src/validation/index.ts @@ -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'; diff --git a/src/mcpd/src/validation/mcp-profile.schema.ts b/src/mcpd/src/validation/mcp-profile.schema.ts new file mode 100644 index 0000000..7ea4ce4 --- /dev/null +++ b/src/mcpd/src/validation/mcp-profile.schema.ts @@ -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; +export type UpdateMcpProfileInput = z.infer; diff --git a/src/mcpd/src/validation/mcp-server.schema.ts b/src/mcpd/src/validation/mcp-server.schema.ts new file mode 100644 index 0000000..1a2e217 --- /dev/null +++ b/src/mcpd/src/validation/mcp-server.schema.ts @@ -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; +export type UpdateMcpServerInput = z.infer; diff --git a/src/mcpd/tests/mcp-profile-service.test.ts b/src/mcpd/tests/mcp-profile-service.test.ts new file mode 100644 index 0000000..ef9a6c5 --- /dev/null +++ b/src/mcpd/tests/mcp-profile-service.test.ts @@ -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; + let serverRepo: ReturnType; + 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); + }); + }); +}); diff --git a/src/mcpd/tests/mcp-server-routes.test.ts b/src/mcpd/tests/mcp-server-routes.test.ts new file mode 100644 index 0000000..95f7cb8 --- /dev/null +++ b/src/mcpd/tests/mcp-server-routes.test.ts @@ -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>(); + 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); + }); + }); +}); diff --git a/src/mcpd/tests/mcp-server-service.test.ts b/src/mcpd/tests/mcp-server-service.test.ts new file mode 100644 index 0000000..5e52328 --- /dev/null +++ b/src/mcpd/tests/mcp-server-service.test.ts @@ -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; + 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); + }); + }); +}); diff --git a/src/mcpd/tests/validation.test.ts b/src/mcpd/tests/validation.test.ts new file mode 100644 index 0000000..1529ebc --- /dev/null +++ b/src/mcpd/tests/validation.test.ts @@ -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(); + }); +});