From da90f01dc18ff1184ac38fc21f0f79c85d2e9104 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 04:30:36 +0000 Subject: [PATCH] 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 --- src/mcpd/src/repositories/index.ts | 2 + .../src/repositories/project.repository.ts | 69 +++++++++ src/mcpd/src/routes/index.ts | 1 + src/mcpd/src/routes/projects.ts | 43 ++++++ src/mcpd/src/services/index.ts | 3 + src/mcpd/src/services/mcp-config-generator.ts | 59 +++++++ src/mcpd/src/services/project.service.ts | 86 +++++++++++ src/mcpd/src/validation/index.ts | 2 + src/mcpd/src/validation/project.schema.ts | 18 +++ src/mcpd/tests/mcp-config-generator.test.ts | 109 +++++++++++++ src/mcpd/tests/project-service.test.ts | 145 ++++++++++++++++++ 11 files changed, 537 insertions(+) create mode 100644 src/mcpd/src/repositories/project.repository.ts create mode 100644 src/mcpd/src/routes/projects.ts create mode 100644 src/mcpd/src/services/mcp-config-generator.ts create mode 100644 src/mcpd/src/services/project.service.ts create mode 100644 src/mcpd/src/validation/project.schema.ts create mode 100644 src/mcpd/tests/mcp-config-generator.test.ts create mode 100644 src/mcpd/tests/project-service.test.ts diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 6c2a23b..497d881 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -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'; diff --git a/src/mcpd/src/repositories/project.repository.ts b/src/mcpd/src/repositories/project.repository.ts new file mode 100644 index 0000000..8980ddc --- /dev/null +++ b/src/mcpd/src/repositories/project.repository.ts @@ -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; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: CreateProjectInput & { ownerId: string }): Promise; + update(id: string, data: UpdateProjectInput): Promise; + delete(id: string): Promise; + setProfiles(projectId: string, profileIds: string[]): Promise; + getProfileIds(projectId: string): Promise; +} + +export class ProjectRepository implements IProjectRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(ownerId?: string): Promise { + const where = ownerId !== undefined ? { ownerId } : {}; + return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.project.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.project.findUnique({ where: { name } }); + } + + async create(data: CreateProjectInput & { ownerId: string }): Promise { + return this.prisma.project.create({ + data: { + name: data.name, + description: data.description, + ownerId: data.ownerId, + }, + }); + } + + async update(id: string, data: UpdateProjectInput): Promise { + const updateData: Record = {}; + if (data.description !== undefined) updateData['description'] = data.description; + return this.prisma.project.update({ where: { id }, data: updateData }); + } + + async delete(id: string): Promise { + await this.prisma.project.delete({ where: { id } }); + } + + async setProfiles(projectId: string, profileIds: string[]): Promise { + 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 { + const links = await this.prisma.projectMcpProfile.findMany({ + where: { projectId }, + select: { profileId: true }, + }); + return links.map((l) => l.profileId); + } +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index 79e2180..0c0c777 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -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'; diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts new file mode 100644 index 0000000..73bc54b --- /dev/null +++ b/src/mcpd/src/routes/projects.ts @@ -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); + }); +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index ab36547..f199f69 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -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'; diff --git a/src/mcpd/src/services/mcp-config-generator.ts b/src/mcpd/src/services/mcp-config-generator.ts new file mode 100644 index 0000000..8be1164 --- /dev/null +++ b/src/mcpd/src/services/mcp-config-generator.ts @@ -0,0 +1,59 @@ +import type { McpServer, McpProfile } from '@prisma/client'; + +export interface McpConfigServer { + command: string; + args: string[]; + env?: Record; +} + +export interface McpConfig { + mcpServers: Record; +} + +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 = {}; + + 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; + + // Build env: only include non-secret env vars + const env: Record = {}; + 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 }; +} diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts new file mode 100644 index 0000000..ef763cc --- /dev/null +++ b/src/mcpd/src/services/project.service.ts @@ -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 { + return this.projectRepo.findAll(ownerId); + } + + async getById(id: string): Promise { + 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 { + 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 { + const data = UpdateProjectSchema.parse(input); + await this.getById(id); + return this.projectRepo.update(id, data); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.projectRepo.delete(id); + } + + async setProfiles(projectId: string, input: unknown): Promise { + 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 { + await this.getById(projectId); + return this.projectRepo.getProfileIds(projectId); + } + + async getMcpConfig(projectId: string): Promise { + 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); + } +} diff --git a/src/mcpd/src/validation/index.ts b/src/mcpd/src/validation/index.ts index 7758e07..a879147 100644 --- a/src/mcpd/src/validation/index.ts +++ b/src/mcpd/src/validation/index.ts @@ -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'; diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts new file mode 100644 index 0000000..95ec6a9 --- /dev/null +++ b/src/mcpd/src/validation/project.schema.ts @@ -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; +export type UpdateProjectInput = z.infer; +export type UpdateProjectProfilesInput = z.infer; diff --git a/src/mcpd/tests/mcp-config-generator.test.ts b/src/mcpd/tests/mcp-config-generator.test.ts new file mode 100644 index 0000000..a4817e0 --- /dev/null +++ b/src/mcpd/tests/mcp-config-generator.test.ts @@ -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'] { + return { + id: 'p1', + name: 'default', + serverId: 's1', + permissions: [], + envOverrides: {}, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeServer(overrides: Partial = {}): 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']); + }); +}); diff --git a/src/mcpd/tests/project-service.test.ts b/src/mcpd/tests/project-service.test.ts new file mode 100644 index 0000000..fc75433 --- /dev/null +++ b/src/mcpd/tests/project-service.test.ts @@ -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; + let profileRepo: ReturnType; + let serverRepo: ReturnType; + 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'); + }); + }); +});