Files
mcpctl/src/mcpd/src/services/project.service.ts
Michal 783cf15179
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
- 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>
2026-02-23 17:50:01 +00:00

154 lines
5.5 KiB
TypeScript

import type { McpServer } from '@prisma/client';
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
import { resolveServerEnv } from './env-resolver.js';
import { generateMcpConfig } from './mcp-config-generator.js';
import type { McpConfig } from './mcp-config-generator.js';
export class ProjectService {
constructor(
private readonly projectRepo: IProjectRepository,
private readonly serverRepo: IMcpServerRepository,
private readonly secretRepo: ISecretRepository,
) {}
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
return this.projectRepo.findAll(ownerId);
}
async getById(id: string): Promise<ProjectWithRelations> {
const project = await this.projectRepo.findById(id);
if (project === null) {
throw new NotFoundError(`Project not found: ${id}`);
}
return project;
}
/** Resolve by ID or name. */
async resolveAndGet(idOrName: string): Promise<ProjectWithRelations> {
// Try by ID first
const byId = await this.projectRepo.findById(idOrName);
if (byId !== null) return byId;
// Fall back to name
const byName = await this.projectRepo.findByName(idOrName);
if (byName !== null) return byName;
throw new NotFoundError(`Project not found: ${idOrName}`);
}
async create(input: unknown, ownerId: string): Promise<ProjectWithRelations> {
const data = CreateProjectSchema.parse(input);
const existing = await this.projectRepo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Project already exists: ${data.name}`);
}
// Resolve server names to IDs
const serverIds = await this.resolveServerNames(data.servers);
const project = await this.projectRepo.create({
name: data.name,
description: data.description,
ownerId,
proxyMode: data.proxyMode,
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
});
// Link servers
if (serverIds.length > 0) {
await this.projectRepo.setServers(project.id, serverIds);
}
// Re-fetch to include relations
return this.getById(project.id);
}
async update(id: string, input: unknown): Promise<ProjectWithRelations> {
const data = UpdateProjectSchema.parse(input);
const project = await this.getById(id);
// Build update data for scalar fields
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
// Update scalar fields if any changed
if (Object.keys(updateData).length > 0) {
await this.projectRepo.update(project.id, updateData);
}
// Update servers if provided
if (data.servers !== undefined) {
const serverIds = await this.resolveServerNames(data.servers);
await this.projectRepo.setServers(project.id, serverIds);
}
// Re-fetch to include updated relations
return this.getById(project.id);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.projectRepo.delete(id);
}
async generateMcpConfig(idOrName: string): Promise<McpConfig> {
const project = await this.resolveAndGet(idOrName);
if (project.proxyMode === 'filtered') {
// Single entry pointing at mcplocal proxy
return {
mcpServers: {
[project.name]: {
url: `http://localhost:3100/api/v1/mcp/proxy/project/${project.name}`,
},
},
};
}
// Direct mode: fetch full servers and resolve env
const serverEntries: Array<{ server: McpServer; resolvedEnv: Record<string, string> }> = [];
for (const ps of project.servers) {
const server = await this.serverRepo.findById(ps.server.id);
if (server === null) continue;
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
serverEntries.push({ server, resolvedEnv });
}
return generateMcpConfig(serverEntries);
}
async addServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
const project = await this.resolveAndGet(idOrName);
const server = await this.serverRepo.findByName(serverName);
if (server === null) throw new NotFoundError(`Server not found: ${serverName}`);
await this.projectRepo.addServer(project.id, server.id);
return this.getById(project.id);
}
async removeServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
const project = await this.resolveAndGet(idOrName);
const server = await this.serverRepo.findByName(serverName);
if (server === null) throw new NotFoundError(`Server not found: ${serverName}`);
await this.projectRepo.removeServer(project.id, server.id);
return this.getById(project.id);
}
private async resolveServerNames(names: string[]): Promise<string[]> {
return Promise.all(names.map(async (name) => {
const server = await this.serverRepo.findByName(name);
if (server === null) throw new NotFoundError(`Server not found: ${name}`);
return server.id;
}));
}
}