- Remove ProjectMember model entirely (RBAC manages project access) - Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose) - Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model - Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER) - Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach - Remove members from backup/restore, apply, get, describe - Prisma migration to drop ProjectMember table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
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, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
|
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
|
import type { McpServer } from '@prisma/client';
|
|
|
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
|
return {
|
|
id: 'proj-1',
|
|
name: 'test-project',
|
|
description: '',
|
|
ownerId: 'user-1',
|
|
proxyMode: 'direct',
|
|
llmProvider: null,
|
|
llmModel: null,
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
servers: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
|
return {
|
|
id: 'srv-1',
|
|
name: 'test-server',
|
|
description: '',
|
|
packageName: '@mcp/test',
|
|
dockerImage: null,
|
|
transport: 'STDIO',
|
|
repositoryUrl: null,
|
|
externalUrl: null,
|
|
command: null,
|
|
containerPort: null,
|
|
replicas: 1,
|
|
env: [],
|
|
healthCheck: null,
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
templateName: null,
|
|
templateVersion: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockProjectRepo(): IProjectRepository {
|
|
return {
|
|
findAll: vi.fn(async () => []),
|
|
findById: vi.fn(async () => null),
|
|
findByName: vi.fn(async () => null),
|
|
create: vi.fn(async (data) => makeProject({
|
|
name: data.name,
|
|
description: data.description,
|
|
ownerId: data.ownerId,
|
|
proxyMode: data.proxyMode,
|
|
llmProvider: data.llmProvider ?? null,
|
|
llmModel: data.llmModel ?? null,
|
|
})),
|
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
|
delete: vi.fn(async () => {}),
|
|
setServers: vi.fn(async () => {}),
|
|
addServer: vi.fn(async () => {}),
|
|
removeServer: 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 () => makeServer()),
|
|
update: vi.fn(async () => makeServer()),
|
|
delete: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
function mockSecretRepo(): ISecretRepository {
|
|
return {
|
|
findAll: vi.fn(async () => []),
|
|
findById: vi.fn(async () => null),
|
|
findByName: vi.fn(async () => null),
|
|
create: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
|
update: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
|
delete: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
describe('ProjectService', () => {
|
|
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
|
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
|
let service: ProjectService;
|
|
|
|
beforeEach(() => {
|
|
projectRepo = mockProjectRepo();
|
|
serverRepo = mockServerRepo();
|
|
secretRepo = mockSecretRepo();
|
|
service = new ProjectService(projectRepo, serverRepo, secretRepo);
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('creates a basic project', async () => {
|
|
// After create, getById is called to re-fetch with relations
|
|
const created = makeProject({ name: 'my-project', ownerId: 'user-1' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
|
|
|
const result = await service.create({ name: 'my-project' }, 'user-1');
|
|
expect(result.name).toBe('my-project');
|
|
expect(result.ownerId).toBe('user-1');
|
|
expect(projectRepo.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws ConflictError when name exists', async () => {
|
|
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject());
|
|
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
|
|
});
|
|
|
|
it('validates input', async () => {
|
|
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
|
|
});
|
|
|
|
it('creates project with servers (resolves names)', async () => {
|
|
const srv1 = makeServer({ id: 'srv-1', name: 'github' });
|
|
const srv2 = makeServer({ id: 'srv-2', name: 'slack' });
|
|
vi.mocked(serverRepo.findByName).mockImplementation(async (name) => {
|
|
if (name === 'github') return srv1;
|
|
if (name === 'slack') return srv2;
|
|
return null;
|
|
});
|
|
|
|
const created = makeProject({ id: 'proj-new' });
|
|
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
|
id: 'proj-new',
|
|
servers: [
|
|
{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } },
|
|
{ id: 'ps-2', server: { id: 'srv-2', name: 'slack' } },
|
|
],
|
|
}));
|
|
|
|
const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1');
|
|
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-2']);
|
|
expect(result.servers).toHaveLength(2);
|
|
});
|
|
|
|
it('creates project with proxyMode and llmProvider', async () => {
|
|
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
|
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
|
|
|
const result = await service.create({
|
|
name: 'filtered-proj',
|
|
proxyMode: 'filtered',
|
|
llmProvider: 'openai',
|
|
}, 'user-1');
|
|
|
|
expect(result.proxyMode).toBe('filtered');
|
|
expect(result.llmProvider).toBe('openai');
|
|
});
|
|
|
|
it('rejects filtered project without llmProvider', async () => {
|
|
await expect(
|
|
service.create({ name: 'bad-proj', proxyMode: 'filtered' }, 'user-1'),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('throws NotFoundError when server name resolution fails', async () => {
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'),
|
|
).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
});
|
|
|
|
describe('getById', () => {
|
|
it('throws NotFoundError when not found', async () => {
|
|
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('returns project when found', async () => {
|
|
const proj = makeProject({ id: 'found' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
|
const result = await service.getById('found');
|
|
expect(result.id).toBe('found');
|
|
});
|
|
});
|
|
|
|
describe('resolveAndGet', () => {
|
|
it('finds by ID first', async () => {
|
|
const proj = makeProject({ id: 'proj-id' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
|
const result = await service.resolveAndGet('proj-id');
|
|
expect(result.id).toBe('proj-id');
|
|
});
|
|
|
|
it('falls back to name when ID not found', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
|
const proj = makeProject({ name: 'my-name' });
|
|
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
|
|
const result = await service.resolveAndGet('my-name');
|
|
expect(result.name).toBe('my-name');
|
|
});
|
|
|
|
it('throws NotFoundError when neither ID nor name found', async () => {
|
|
await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('updates servers (full replacement)', async () => {
|
|
const existing = makeProject({ id: 'proj-1' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
|
|
|
const srv = makeServer({ id: 'srv-new', name: 'new-srv' });
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
|
|
|
await service.update('proj-1', { servers: ['new-srv'] });
|
|
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
|
});
|
|
|
|
it('updates proxyMode', async () => {
|
|
const existing = makeProject({ id: 'proj-1' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
|
|
|
await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' });
|
|
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
|
|
proxyMode: 'filtered',
|
|
llmProvider: 'anthropic',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('deletes project', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await service.delete('p1');
|
|
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
|
|
});
|
|
|
|
it('throws NotFoundError when project does not exist', async () => {
|
|
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('addServer', () => {
|
|
it('attaches a server by name', async () => {
|
|
const project = makeProject({ id: 'proj-1' });
|
|
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
|
|
|
await service.addServer('proj-1', 'my-ha');
|
|
expect(projectRepo.addServer).toHaveBeenCalledWith('proj-1', 'srv-1');
|
|
});
|
|
|
|
it('throws NotFoundError when project not found', async () => {
|
|
await expect(service.addServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('throws NotFoundError when server not found', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
|
|
|
await expect(service.addServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('removeServer', () => {
|
|
it('detaches a server by name', async () => {
|
|
const project = makeProject({ id: 'proj-1' });
|
|
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
|
|
|
await service.removeServer('proj-1', 'my-ha');
|
|
expect(projectRepo.removeServer).toHaveBeenCalledWith('proj-1', 'srv-1');
|
|
});
|
|
|
|
it('throws NotFoundError when project not found', async () => {
|
|
await expect(service.removeServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('throws NotFoundError when server not found', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
|
|
|
await expect(service.removeServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('generateMcpConfig', () => {
|
|
it('generates direct mode config with STDIO servers', async () => {
|
|
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
name: 'my-proj',
|
|
proxyMode: 'direct',
|
|
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
|
|
|
const config = await service.generateMcpConfig('proj-1');
|
|
expect(config.mcpServers['github']).toBeDefined();
|
|
expect(config.mcpServers['github']?.command).toBe('npx');
|
|
expect(config.mcpServers['github']?.args).toEqual(['-y', '@mcp/github']);
|
|
});
|
|
|
|
it('generates direct mode config with SSE servers (URL-based)', async () => {
|
|
const srv = makeServer({ id: 'srv-2', name: 'sse-server', transport: 'SSE' });
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
proxyMode: 'direct',
|
|
servers: [{ id: 'ps-1', server: { id: 'srv-2', name: 'sse-server' } }],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
|
|
|
const config = await service.generateMcpConfig('proj-1');
|
|
expect(config.mcpServers['sse-server']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server');
|
|
expect(config.mcpServers['sse-server']?.command).toBeUndefined();
|
|
});
|
|
|
|
it('generates filtered mode config (single mcplocal entry)', async () => {
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
name: 'filtered-proj',
|
|
proxyMode: 'filtered',
|
|
llmProvider: 'openai',
|
|
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
|
|
const config = await service.generateMcpConfig('proj-1');
|
|
expect(Object.keys(config.mcpServers)).toHaveLength(1);
|
|
expect(config.mcpServers['filtered-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/filtered-proj');
|
|
});
|
|
|
|
it('resolves by name for mcp-config', async () => {
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
name: 'my-proj',
|
|
proxyMode: 'direct',
|
|
servers: [],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
|
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
|
|
|
|
const config = await service.generateMcpConfig('my-proj');
|
|
expect(config.mcpServers).toEqual({});
|
|
});
|
|
|
|
it('includes env for STDIO servers', async () => {
|
|
const srv = makeServer({
|
|
id: 'srv-1',
|
|
name: 'github',
|
|
transport: 'STDIO',
|
|
env: [{ name: 'GITHUB_TOKEN', value: 'tok123' }],
|
|
});
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
proxyMode: 'direct',
|
|
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
|
|
|
const config = await service.generateMcpConfig('proj-1');
|
|
expect(config.mcpServers['github']?.env?.['GITHUB_TOKEN']).toBe('tok123');
|
|
});
|
|
});
|
|
});
|