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 c1fe5c9..8fe86b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 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(); + }); +});