feat: add MCP server and profile management API
Add validation schemas (Zod), repository pattern with Prisma, service layer with business logic (NotFoundError, ConflictError), and REST routes for MCP server and profile CRUD. 86 mcpd tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -233,7 +233,7 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"3"
|
"3"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -294,7 +294,8 @@
|
|||||||
"testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks.",
|
"testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks.",
|
||||||
"parentId": "undefined"
|
"parentId": "undefined"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"updatedAt": "2026-02-21T04:26:06.239Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "5",
|
"id": "5",
|
||||||
@@ -731,9 +732,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-02-21T04:21:50.389Z",
|
"lastModified": "2026-02-21T04:26:06.239Z",
|
||||||
"taskCount": 24,
|
"taskCount": 24,
|
||||||
"completedCount": 4,
|
"completedCount": 5,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -106,6 +106,9 @@ importers:
|
|||||||
'@mcpctl/shared':
|
'@mcpctl/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
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:
|
fastify:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.7.4
|
version: 5.7.4
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@fastify/rate-limit": "^10.0.0",
|
"@fastify/rate-limit": "^10.0.0",
|
||||||
"@mcpctl/db": "workspace:*",
|
"@mcpctl/db": "workspace:*",
|
||||||
"@mcpctl/shared": "workspace:*",
|
"@mcpctl/shared": "workspace:*",
|
||||||
|
"@prisma/client": "^6.0.0",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function errorHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known HTTP errors
|
// Known HTTP errors (includes service errors like NotFoundError, ConflictError)
|
||||||
const statusCode = error.statusCode ?? 500;
|
const statusCode = error.statusCode ?? 500;
|
||||||
if (statusCode < 500) {
|
if (statusCode < 500) {
|
||||||
reply.code(statusCode).send({
|
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 { registerHealthRoutes } from './health.js';
|
||||||
export type { HealthDeps } 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