feat: remove proxyMode — all traffic goes through mcplocal proxy

proxyMode "direct" was a security hole (leaked secrets as plaintext env
vars in .mcp.json) and bypassed all mcplocal features (gating, audit,
RBAC, content pipeline, namespacing). Removed from schema, API, CLI,
and all tests. Old configs with proxyMode are accepted but silently
stripped via Zod .transform() for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 23:36:36 +00:00
parent d9d0a7a374
commit 0995851810
28 changed files with 69 additions and 221 deletions

View File

@@ -125,7 +125,6 @@ const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
proxyModel: z.string().optional(),
gated: z.boolean().optional(),
llmProvider: z.string().optional(),

View File

@@ -225,7 +225,6 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--proxy-model <name>', 'Plugin name (default, content-pipeline, gate, none)')
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
.option('--gated', '[deprecated: use --proxy-model default]')
@@ -236,7 +235,6 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const body: Record<string, unknown> = {
name,
description: opts.description,
proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.prompt) body.prompt = opts.prompt;
if (opts.proxyModel) {

View File

@@ -143,8 +143,7 @@ function formatProjectDetail(
lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
// Plugin & proxy config
const proxyMode = project.proxyMode as string | undefined;
// Plugin config
const proxyModel = (project.proxyModel as string | undefined) || 'default';
const llmProvider = project.llmProvider as string | undefined;
const llmModel = project.llmModel as string | undefined;
@@ -152,7 +151,6 @@ function formatProjectDetail(
lines.push('');
lines.push('Plugin Config:');
lines.push(` ${pad('Plugin:', 18)}${proxyModel}`);
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);

View File

@@ -23,7 +23,6 @@ interface ProjectRow {
id: string;
name: string;
description: string;
proxyMode: string;
proxyModel: string;
gated?: boolean;
ownerId: string;
@@ -86,7 +85,6 @@ interface RbacRow {
const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
{ header: 'PLUGIN', key: (r) => r.proxyModel || 'default', width: 18 },
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 30 },

View File

@@ -332,7 +332,6 @@ rbacBindings:
projects:
- name: smart-home
description: Home automation
proxyMode: filtered
llmProvider: gemini-cli
llmModel: gemini-2.0-flash
servers:
@@ -345,7 +344,6 @@ projects:
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],

View File

@@ -175,7 +175,6 @@ describe('create command', () => {
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
proxyMode: 'direct',
});
expect(output.join('\n')).toContain("project 'test' created");
});
@@ -186,7 +185,6 @@ describe('create command', () => {
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
proxyMode: 'direct',
});
});
@@ -195,7 +193,7 @@ describe('create command', () => {
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated', proxyMode: 'direct' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' });
expect(output.join('\n')).toContain("project 'my-proj' updated");
});
});

View File

@@ -96,7 +96,6 @@ describe('describe command', () => {
description: 'A gated project',
ownerId: 'user-1',
proxyModel: 'default',
proxyMode: 'direct',
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);

View File

@@ -179,7 +179,6 @@ describe('get command', () => {
id: 'proj-1',
name: 'smart-home',
description: 'Home automation',
proxyMode: 'filtered',
ownerId: 'usr-1',
servers: [{ server: { name: 'grafana' } }],
}]);
@@ -187,10 +186,8 @@ describe('get command', () => {
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('smart-home');
expect(text).toContain('filtered');
expect(text).toContain('1');
});
@@ -341,7 +338,6 @@ describe('get command', () => {
id: 'proj-1',
name: 'home',
description: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
ownerId: 'usr-1',
@@ -362,7 +358,6 @@ describe('get command', () => {
id: 'proj-1',
name: 'home',
description: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
ownerId: 'usr-1',
@@ -381,7 +376,6 @@ describe('get command', () => {
id: 'proj-1',
name: 'tools',
description: '',
proxyMode: 'direct',
proxyModel: '',
gated: false,
ownerId: 'usr-1',
@@ -400,7 +394,6 @@ describe('get command', () => {
id: 'proj-1',
name: 'custom',
description: '',
proxyMode: 'direct',
proxyModel: 'gate',
gated: true,
ownerId: 'usr-1',

View File

@@ -24,12 +24,11 @@ describe('project with new fields', () => {
});
describe('create project with enhanced options', () => {
it('creates project with proxy mode and servers', async () => {
it('creates project with servers', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'project', 'smart-home',
'-d', 'Smart home project',
'--proxy-mode', 'filtered',
'--server', 'my-grafana',
'--server', 'my-ha',
], { from: 'user' });
@@ -37,30 +36,19 @@ describe('project with new fields', () => {
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
description: 'Smart home project',
proxyMode: 'filtered',
servers: ['my-grafana', 'my-ha'],
}));
});
it('defaults proxy mode to direct', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
proxyMode: 'direct',
}));
});
});
describe('get projects shows new columns', () => {
it('shows MODE and SERVERS columns', async () => {
it('shows SERVERS column', async () => {
const deps = {
output: [] as string[],
fetchResource: vi.fn(async () => [{
id: 'proj-1',
name: 'smart-home',
description: 'Test',
proxyMode: 'filtered',
ownerId: 'user-1',
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
}]),
@@ -70,14 +58,13 @@ describe('project with new fields', () => {
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('smart-home');
});
});
describe('describe project shows full detail', () => {
it('shows servers and proxy config', async () => {
it('shows servers and LLM config', async () => {
const deps = {
output: [] as string[],
client: mockClient(),
@@ -85,7 +72,6 @@ describe('project with new fields', () => {
id: 'proj-1',
name: 'smart-home',
description: 'Smart home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
ownerId: 'user-1',
@@ -103,7 +89,6 @@ describe('project with new fields', () => {
const text = deps.output.join('\n');
expect(text).toContain('=== Project: smart-home ===');
expect(text).toContain('filtered');
expect(text).toContain('gemini-cli');
expect(text).toContain('my-grafana');
expect(text).toContain('my-ha');

View File

@@ -0,0 +1,2 @@
-- Remove proxyMode column (redundant — all traffic goes through mcplocal proxy)
ALTER TABLE "Project" DROP COLUMN IF EXISTS "proxyMode";

View File

@@ -173,7 +173,6 @@ model Project {
name String @unique
description String @default("")
prompt String @default("")
proxyMode String @default("direct")
proxyModel String @default("")
gated Boolean @default(true)
llmProvider String?

View File

@@ -506,23 +506,16 @@ describe('ProjectServer', () => {
// ── Project new fields ──
describe('Project new fields', () => {
it('defaults proxyMode to direct', async () => {
const project = await createProject();
expect(project.proxyMode).toBe('direct');
});
it('stores proxyMode, llmProvider, llmModel', async () => {
it('stores llmProvider, llmModel', async () => {
const user = await createUser();
const project = await prisma.project.create({
data: {
name: 'filtered-project',
ownerId: user.id,
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
},
});
expect(project.proxyMode).toBe('filtered');
expect(project.llmProvider).toBe('gemini-cli');
expect(project.llmModel).toBe('gemini-2.0-flash');
});

View File

@@ -144,7 +144,6 @@ export async function bootstrapSystemProject(prisma: PrismaClient): Promise<void
name: SYSTEM_PROJECT_NAME,
description: 'System prompts for mcpctl gating and session management',
prompt: '',
proxyMode: 'direct',
gated: false,
ownerId: systemUser.id,
},

View File

@@ -276,7 +276,7 @@ async function main(): Promise<void> {
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
serverService.setInstanceService(instanceService);
const secretService = new SecretService(secretRepo);
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo);
const projectService = new ProjectService(projectRepo, serverRepo);
const auditLogService = new AuditLogService(auditLogRepo);
const auditEventService = new AuditEventService(auditEventRepo);
const metricsCollector = new MetricsCollector();

View File

@@ -12,7 +12,7 @@ export interface IProjectRepository {
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: string): Promise<ProjectWithRelations | null>;
findByName(name: string): Promise<ProjectWithRelations | null>;
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown> }): Promise<ProjectWithRelations>;
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown> }): Promise<ProjectWithRelations>;
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
delete(id: string): Promise<void>;
setServers(projectId: string, serverIds: string[]): Promise<void>;
@@ -36,12 +36,11 @@ export class ProjectRepository implements IProjectRepository {
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
}
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown> }): Promise<ProjectWithRelations> {
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown> }): Promise<ProjectWithRelations> {
const createData: Record<string, unknown> = {
name: data.name,
description: data.description,
ownerId: data.ownerId,
proxyMode: data.proxyMode,
};
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
if (data.proxyModel !== undefined) createData['proxyModel'] = data.proxyModel;

View File

@@ -116,7 +116,6 @@ export class BackupService {
projects = allProjects.map((proj) => ({
name: proj.name,
description: proj.description,
proxyMode: proj.proxyMode,
proxyModel: proj.proxyModel,
llmProvider: proj.llmProvider,
llmModel: proj.llmModel,

View File

@@ -255,7 +255,6 @@ export class RestoreService {
}
// overwrite
const updateData: Record<string, unknown> = { description: project.description };
if (project.proxyMode) updateData['proxyMode'] = project.proxyMode;
if (project.proxyModel) updateData['proxyModel'] = project.proxyModel;
if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider;
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
@@ -271,11 +270,10 @@ export class RestoreService {
continue;
}
const projectCreateData: { name: string; description: string; ownerId: string; proxyMode: string; proxyModel?: string; llmProvider?: string; llmModel?: string } = {
const projectCreateData: { name: string; description: string; ownerId: string; proxyModel?: string; llmProvider?: string; llmModel?: string } = {
name: project.name,
description: project.description,
ownerId: 'system',
proxyMode: project.proxyMode ?? 'direct',
};
if (project.proxyModel) projectCreateData.proxyModel = project.proxyModel;
if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider;

View File

@@ -1,17 +1,13 @@
import type { McpServer } from '@prisma/client';
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
import type { IMcpServerRepository } 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[]> {
@@ -55,7 +51,6 @@ export class ProjectService {
description: data.description,
prompt: data.prompt,
ownerId,
proxyMode: data.proxyMode,
proxyModel: data.proxyModel,
gated: data.gated,
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
@@ -80,7 +75,6 @@ export class ProjectService {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.prompt !== undefined) updateData['prompt'] = data.prompt;
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
if (data.proxyModel !== undefined) updateData['proxyModel'] = data.proxyModel;
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
@@ -110,29 +104,14 @@ export class ProjectService {
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}`,
},
// All traffic goes through mcplocal proxy — single entry
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> {

View File

@@ -4,7 +4,6 @@ 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(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
proxyModel: z.string().max(100).default(''),
gated: z.boolean().default(true),
llmProvider: z.string().max(100).optional(),
@@ -13,15 +12,13 @@ export const CreateProjectSchema = z.object({
serverOverrides: z.record(z.string(), z.object({
proxyModel: z.string().optional(),
})).optional(),
}).refine(
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
{ message: 'llmProvider is required when proxyMode is "filtered"' },
);
// Backward compat: accept but ignore proxyMode from old configs
proxyMode: z.string().optional(),
}).transform(({ proxyMode: _ignored, ...rest }) => rest);
export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(),
prompt: z.string().max(10000).optional(),
proxyMode: z.enum(['direct', 'filtered']).optional(),
proxyModel: z.string().max(100).optional(),
gated: z.boolean().optional(),
llmProvider: z.string().max(100).nullable().optional(),
@@ -30,7 +27,9 @@ export const UpdateProjectSchema = z.object({
serverOverrides: z.record(z.string(), z.object({
proxyModel: z.string().optional(),
})).optional(),
});
// Backward compat: accept but ignore proxyMode from old configs
proxyMode: z.string().optional(),
}).transform(({ proxyMode: _ignored, ...rest }) => rest);
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;

View File

@@ -34,7 +34,7 @@ const mockSecrets = [
const mockProjects = [
{
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', proxyModel: '', llmProvider: null, llmModel: null,
id: 'proj1', name: 'my-project', description: 'Test project', proxyModel: '', llmProvider: null, llmModel: null,
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
},
@@ -217,7 +217,6 @@ describe('BackupService', () => {
it('includes enriched projects with server names', async () => {
const bundle = await backupService.createBackup();
const proj = bundle.projects[0]!;
expect(proj.proxyMode).toBe('direct');
expect(proj.serverNames).toEqual(['github']);
});
@@ -322,7 +321,7 @@ describe('RestoreService', () => {
{ name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] },
],
projects: [
{ name: 'test-proj', description: 'Test', proxyMode: 'filtered', llmProvider: 'openai', llmModel: 'gpt-4', serverNames: ['github'], members: ['alice@test.com'] },
{ name: 'test-proj', description: 'Test', llmProvider: 'openai', llmModel: 'gpt-4', serverNames: ['github'], members: ['alice@test.com'] },
],
};
@@ -423,7 +422,6 @@ describe('RestoreService', () => {
expect(result.projectsCreated).toBe(1);
expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({
name: 'test-proj',
proxyMode: 'filtered',
llmProvider: 'openai',
llmModel: 'gpt-4',
}));

View File

@@ -5,7 +5,7 @@ import { registerProjectRoutes } from '../src/routes/projects.js';
import { ProjectService } from '../src/services/project.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
let app: FastifyInstance;
@@ -15,7 +15,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
name: 'test-project',
description: '',
ownerId: 'user-1',
proxyMode: 'direct',
prompt: '',
proxyModel: '',
gated: true,
@@ -39,7 +38,6 @@ function mockProjectRepo(): IProjectRepository {
name: data.name,
description: data.description,
ownerId: data.ownerId,
proxyMode: data.proxyMode,
})),
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
delete: vi.fn(async () => {}),
@@ -60,17 +58,6 @@ function mockServerRepo(): IMcpServerRepository {
};
}
function mockSecretRepo(): ISecretRepository {
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 () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
@@ -78,7 +65,7 @@ afterEach(async () => {
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo(), mockSecretRepo());
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo());
registerProjectRoutes(app, service);
return app.ready();
}

View File

@@ -2,7 +2,7 @@ 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 { IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpServer } from '@prisma/client';
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
@@ -11,7 +11,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
name: 'test-project',
description: '',
ownerId: 'user-1',
proxyMode: 'direct',
proxyModel: '',
gated: true,
llmProvider: null,
@@ -57,7 +56,6 @@ function mockProjectRepo(): IProjectRepository {
name: data.name,
description: data.description,
ownerId: data.ownerId,
proxyMode: data.proxyMode,
llmProvider: data.llmProvider ?? null,
llmModel: data.llmModel ?? null,
})),
@@ -80,28 +78,15 @@ function mockServerRepo(): IMcpServerRepository {
};
}
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);
service = new ProjectService(projectRepo, serverRepo);
});
describe('create', () => {
@@ -149,27 +134,6 @@ describe('ProjectService', () => {
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);
@@ -226,13 +190,12 @@ describe('ProjectService', () => {
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
});
it('updates proxyMode', async () => {
it('updates llmProvider', async () => {
const existing = makeProject({ id: 'proj-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' });
await service.update('proj-1', { llmProvider: 'anthropic' });
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
proxyMode: 'filtered',
llmProvider: 'anthropic',
});
});
@@ -297,46 +260,10 @@ describe('ProjectService', () => {
});
describe('generateMcpConfig', () => {
it('generates direct mode config with STDIO servers', async () => {
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
it('generates single mcplocal proxy entry', async () => {
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' } }],
});
@@ -344,14 +271,13 @@ describe('ProjectService', () => {
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');
expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj');
});
it('resolves by name for mcp-config', async () => {
const project = makeProject({
id: 'proj-1',
name: 'my-proj',
proxyMode: 'direct',
servers: [],
});
@@ -359,27 +285,8 @@ describe('ProjectService', () => {
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');
expect(Object.keys(config.mcpServers)).toHaveLength(1);
expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj');
});
});
});

View File

@@ -48,7 +48,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
name: 'homeautomation',
description: '',
prompt: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
llmProvider: null,

View File

@@ -42,7 +42,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
name: 'test-project',
description: '',
prompt: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
llmProvider: null,

View File

@@ -35,7 +35,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
name: 'test-project',
description: '',
prompt: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
llmProvider: null,

View File

@@ -17,7 +17,6 @@ projects:
- name: smoke-data
description: "Smoke test project with 100 AWS documentation prompt links"
gated: true
proxyMode: direct
serverattachments:
- server: smoke-aws-docs