- 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>
154 lines
5.5 KiB
TypeScript
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;
|
|
}));
|
|
}
|
|
}
|