feat: add project management APIs with MCP config generation
Project CRUD, profile association, and MCP config generation that filters secret env vars. 104 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
export type { IMcpServerRepository, IMcpProfileRepository } from './interfaces.js';
|
||||
export { McpServerRepository } from './mcp-server.repository.js';
|
||||
export { McpProfileRepository } from './mcp-profile.repository.js';
|
||||
export type { IProjectRepository } from './project.repository.js';
|
||||
export { ProjectRepository } from './project.repository.js';
|
||||
|
||||
69
src/mcpd/src/repositories/project.repository.ts
Normal file
69
src/mcpd/src/repositories/project.repository.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { PrismaClient, Project } from '@prisma/client';
|
||||
import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js';
|
||||
|
||||
export interface IProjectRepository {
|
||||
findAll(ownerId?: string): Promise<Project[]>;
|
||||
findById(id: string): Promise<Project | null>;
|
||||
findByName(name: string): Promise<Project | null>;
|
||||
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
|
||||
update(id: string, data: UpdateProjectInput): Promise<Project>;
|
||||
delete(id: string): Promise<void>;
|
||||
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
|
||||
getProfileIds(projectId: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export class ProjectRepository implements IProjectRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(ownerId?: string): Promise<Project[]> {
|
||||
const where = ownerId !== undefined ? { ownerId } : {};
|
||||
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Project | null> {
|
||||
return this.prisma.project.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Project | null> {
|
||||
return this.prisma.project.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> {
|
||||
return this.prisma.project.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
ownerId: data.ownerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateProjectInput): Promise<Project> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.description !== undefined) updateData['description'] = data.description;
|
||||
return this.prisma.project.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.project.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }),
|
||||
...profileIds.map((profileId) =>
|
||||
this.prisma.projectMcpProfile.create({
|
||||
data: { projectId, profileId },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async getProfileIds(projectId: string): Promise<string[]> {
|
||||
const links = await this.prisma.projectMcpProfile.findMany({
|
||||
where: { projectId },
|
||||
select: { profileId: true },
|
||||
});
|
||||
return links.map((l) => l.profileId);
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { registerHealthRoutes } from './health.js';
|
||||
export type { HealthDeps } from './health.js';
|
||||
export { registerMcpServerRoutes } from './mcp-servers.js';
|
||||
export { registerMcpProfileRoutes } from './mcp-profiles.js';
|
||||
export { registerProjectRoutes } from './projects.js';
|
||||
|
||||
43
src/mcpd/src/routes/projects.ts
Normal file
43
src/mcpd/src/routes/projects.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { ProjectService } from '../services/project.service.js';
|
||||
|
||||
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
||||
app.get('/api/v1/projects', async (request) => {
|
||||
// If authenticated, filter by owner; otherwise list all
|
||||
return service.list(request.userId);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/projects', async (request, reply) => {
|
||||
const ownerId = request.userId ?? 'anonymous';
|
||||
const project = await service.create(request.body, ownerId);
|
||||
reply.code(201);
|
||||
return project;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// Profile associations
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
|
||||
return service.getProfiles(request.params.id);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
|
||||
return service.setProfiles(request.params.id, request.body);
|
||||
});
|
||||
|
||||
// MCP config generation
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
|
||||
return service.getMcpConfig(request.params.id);
|
||||
});
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
export { McpProfileService } from './mcp-profile.service.js';
|
||||
export { ProjectService } from './project.service.js';
|
||||
export { generateMcpConfig } from './mcp-config-generator.js';
|
||||
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js';
|
||||
|
||||
59
src/mcpd/src/services/mcp-config-generator.ts
Normal file
59
src/mcpd/src/services/mcp-config-generator.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { McpServer, McpProfile } from '@prisma/client';
|
||||
|
||||
export interface McpConfigServer {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface McpConfig {
|
||||
mcpServers: Record<string, McpConfigServer>;
|
||||
}
|
||||
|
||||
export interface ProfileWithServer {
|
||||
profile: McpProfile;
|
||||
server: McpServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .mcp.json config from a project's profiles.
|
||||
* Secret env vars are excluded from the output — they must be injected at runtime.
|
||||
*/
|
||||
export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig {
|
||||
const mcpServers: Record<string, McpConfigServer> = {};
|
||||
|
||||
for (const { profile, server } of profiles) {
|
||||
const key = `${server.name}--${profile.name}`;
|
||||
const envTemplate = server.envTemplate as Array<{
|
||||
name: string;
|
||||
isSecret: boolean;
|
||||
defaultValue?: string;
|
||||
}>;
|
||||
const envOverrides = profile.envOverrides as Record<string, string>;
|
||||
|
||||
// Build env: only include non-secret env vars
|
||||
const env: Record<string, string> = {};
|
||||
for (const entry of envTemplate) {
|
||||
if (entry.isSecret) continue; // Never include secrets in config output
|
||||
const override = envOverrides[entry.name];
|
||||
if (override !== undefined) {
|
||||
env[entry.name] = override;
|
||||
} else if (entry.defaultValue !== undefined) {
|
||||
env[entry.name] = entry.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
const config: McpConfigServer = {
|
||||
command: 'npx',
|
||||
args: ['-y', server.packageName ?? server.name],
|
||||
};
|
||||
|
||||
if (Object.keys(env).length > 0) {
|
||||
config.env = env;
|
||||
}
|
||||
|
||||
mcpServers[key] = config;
|
||||
}
|
||||
|
||||
return { mcpServers };
|
||||
}
|
||||
86
src/mcpd/src/services/project.service.ts
Normal file
86
src/mcpd/src/services/project.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Project } from '@prisma/client';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
import { generateMcpConfig } from './mcp-config-generator.js';
|
||||
import type { McpConfig, ProfileWithServer } from './mcp-config-generator.js';
|
||||
|
||||
export class ProjectService {
|
||||
constructor(
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
private readonly profileRepo: IMcpProfileRepository,
|
||||
private readonly serverRepo: IMcpServerRepository,
|
||||
) {}
|
||||
|
||||
async list(ownerId?: string): Promise<Project[]> {
|
||||
return this.projectRepo.findAll(ownerId);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Project> {
|
||||
const project = await this.projectRepo.findById(id);
|
||||
if (project === null) {
|
||||
throw new NotFoundError(`Project not found: ${id}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async create(input: unknown, ownerId: string): Promise<Project> {
|
||||
const data = CreateProjectSchema.parse(input);
|
||||
|
||||
const existing = await this.projectRepo.findByName(data.name);
|
||||
if (existing !== null) {
|
||||
throw new ConflictError(`Project already exists: ${data.name}`);
|
||||
}
|
||||
|
||||
return this.projectRepo.create({ ...data, ownerId });
|
||||
}
|
||||
|
||||
async update(id: string, input: unknown): Promise<Project> {
|
||||
const data = UpdateProjectSchema.parse(input);
|
||||
await this.getById(id);
|
||||
return this.projectRepo.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await this.projectRepo.delete(id);
|
||||
}
|
||||
|
||||
async setProfiles(projectId: string, input: unknown): Promise<string[]> {
|
||||
const { profileIds } = UpdateProjectProfilesSchema.parse(input);
|
||||
await this.getById(projectId);
|
||||
|
||||
// Verify all profiles exist
|
||||
for (const profileId of profileIds) {
|
||||
const profile = await this.profileRepo.findById(profileId);
|
||||
if (profile === null) {
|
||||
throw new NotFoundError(`Profile not found: ${profileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.projectRepo.setProfiles(projectId, profileIds);
|
||||
return profileIds;
|
||||
}
|
||||
|
||||
async getProfiles(projectId: string): Promise<string[]> {
|
||||
await this.getById(projectId);
|
||||
return this.projectRepo.getProfileIds(projectId);
|
||||
}
|
||||
|
||||
async getMcpConfig(projectId: string): Promise<McpConfig> {
|
||||
await this.getById(projectId);
|
||||
const profileIds = await this.projectRepo.getProfileIds(projectId);
|
||||
|
||||
const profilesWithServers: ProfileWithServer[] = [];
|
||||
for (const profileId of profileIds) {
|
||||
const profile = await this.profileRepo.findById(profileId);
|
||||
if (profile === null) continue;
|
||||
const server = await this.serverRepo.findById(profile.serverId);
|
||||
if (server === null) continue;
|
||||
profilesWithServers.push({ profile, server });
|
||||
}
|
||||
|
||||
return generateMcpConfig(profilesWithServers);
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schem
|
||||
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';
|
||||
export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js';
|
||||
export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js';
|
||||
|
||||
18
src/mcpd/src/validation/project.schema.ts
Normal file
18
src/mcpd/src/validation/project.schema.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateProjectSchema = 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(''),
|
||||
});
|
||||
|
||||
export const UpdateProjectSchema = z.object({
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const UpdateProjectProfilesSchema = z.object({
|
||||
profileIds: z.array(z.string().min(1)).min(0),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
export type UpdateProjectProfilesInput = z.infer<typeof UpdateProjectProfilesSchema>;
|
||||
109
src/mcpd/tests/mcp-config-generator.test.ts
Normal file
109
src/mcpd/tests/mcp-config-generator.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateMcpConfig } from '../src/services/mcp-config-generator.js';
|
||||
import type { ProfileWithServer } from '../src/services/mcp-config-generator.js';
|
||||
|
||||
function makeProfile(overrides: Partial<ProfileWithServer['profile']> = {}): ProfileWithServer['profile'] {
|
||||
return {
|
||||
id: 'p1',
|
||||
name: 'default',
|
||||
serverId: 's1',
|
||||
permissions: [],
|
||||
envOverrides: {},
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): ProfileWithServer['server'] {
|
||||
return {
|
||||
id: 's1',
|
||||
name: 'slack',
|
||||
description: 'Slack MCP',
|
||||
packageName: '@anthropic/slack-mcp',
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('generateMcpConfig', () => {
|
||||
it('returns empty mcpServers for empty profiles', () => {
|
||||
const result = generateMcpConfig([]);
|
||||
expect(result).toEqual({ mcpServers: {} });
|
||||
});
|
||||
|
||||
it('generates config for a single profile', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile(), server: makeServer() },
|
||||
]);
|
||||
expect(result.mcpServers['slack--default']).toBeDefined();
|
||||
expect(result.mcpServers['slack--default']?.command).toBe('npx');
|
||||
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
|
||||
});
|
||||
|
||||
it('excludes secret env vars from output', () => {
|
||||
const server = makeServer({
|
||||
envTemplate: [
|
||||
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
|
||||
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
|
||||
] as never,
|
||||
});
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile(), server },
|
||||
]);
|
||||
const config = result.mcpServers['slack--default'];
|
||||
expect(config?.env).toBeDefined();
|
||||
expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123');
|
||||
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies env overrides from profile (non-secret only)', () => {
|
||||
const server = makeServer({
|
||||
envTemplate: [
|
||||
{ name: 'API_URL', description: 'URL', isSecret: false },
|
||||
] as never,
|
||||
});
|
||||
const profile = makeProfile({
|
||||
envOverrides: { API_URL: 'https://staging.example.com' } as never,
|
||||
});
|
||||
const result = generateMcpConfig([{ profile, server }]);
|
||||
expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com');
|
||||
});
|
||||
|
||||
it('generates multiple server configs', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) },
|
||||
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) },
|
||||
]);
|
||||
expect(Object.keys(result.mcpServers)).toHaveLength(2);
|
||||
expect(result.mcpServers['slack--readonly']).toBeDefined();
|
||||
expect(result.mcpServers['github--default']).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits env when no non-secret vars have values', () => {
|
||||
const server = makeServer({
|
||||
envTemplate: [
|
||||
{ name: 'TOKEN', description: 'Secret', isSecret: true },
|
||||
] as never,
|
||||
});
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile(), server },
|
||||
]);
|
||||
expect(result.mcpServers['slack--default']?.env).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses server name as fallback when packageName is null', () => {
|
||||
const server = makeServer({ packageName: null });
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile(), server },
|
||||
]);
|
||||
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']);
|
||||
});
|
||||
});
|
||||
145
src/mcpd/tests/project-service.test.ts
Normal file
145
src/mcpd/tests/project-service.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProjectService } from '../src/services/project.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'proj-1',
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
ownerId: data.ownerId,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
update: vi.fn(async (id) => ({
|
||||
id, name: 'test', description: '', ownerId: 'u1', version: 2,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
setProfiles: vi.fn(async () => {}),
|
||||
getProfileIds: vi.fn(async () => []),
|
||||
};
|
||||
}
|
||||
|
||||
function mockProfileRepo(): IMcpProfileRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByServerAndName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({} as never)),
|
||||
update: vi.fn(async () => ({} as never)),
|
||||
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('ProjectService', () => {
|
||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||
let profileRepo: ReturnType<typeof mockProfileRepo>;
|
||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||
let service: ProjectService;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRepo = mockProjectRepo();
|
||||
profileRepo = mockProfileRepo();
|
||||
serverRepo = mockServerRepo();
|
||||
service = new ProjectService(projectRepo, profileRepo, serverRepo);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a project', async () => {
|
||||
const result = await service.create({ name: 'my-project' }, 'user-1');
|
||||
expect(result.name).toBe('my-project');
|
||||
expect(result.ownerId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never);
|
||||
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('validates input', async () => {
|
||||
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setProfiles', () => {
|
||||
it('sets profile associations', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
vi.mocked(profileRepo.findById).mockResolvedValue({ id: 'prof-1' } as never);
|
||||
const result = await service.setProfiles('p1', { profileIds: ['prof-1'] });
|
||||
expect(result).toEqual(['prof-1']);
|
||||
expect(projectRepo.setProfiles).toHaveBeenCalledWith('p1', ['prof-1']);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for missing profile', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
await expect(service.setProfiles('p1', { profileIds: ['missing'] })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for missing project', async () => {
|
||||
await expect(service.setProfiles('missing', { profileIds: [] })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMcpConfig', () => {
|
||||
it('returns empty config for project with no profiles', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
const result = await service.getMcpConfig('p1');
|
||||
expect(result).toEqual({ mcpServers: {} });
|
||||
});
|
||||
|
||||
it('generates config from profiles', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
vi.mocked(projectRepo.getProfileIds).mockResolvedValue(['prof-1']);
|
||||
vi.mocked(profileRepo.findById).mockResolvedValue({
|
||||
id: 'prof-1', name: 'default', serverId: 's1',
|
||||
permissions: [], envOverrides: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 's1', name: 'slack', description: '', packageName: '@anthropic/slack-mcp',
|
||||
dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [],
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.getMcpConfig('p1');
|
||||
expect(result.mcpServers['slack--default']).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError for missing project', async () => {
|
||||
await expect(service.getMcpConfig('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes project', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
await service.delete('p1');
|
||||
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user