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>
This commit is contained in:
Michal
2026-02-23 17:50:01 +00:00
parent 1f628d39d2
commit 329315ec71
23 changed files with 283 additions and 219 deletions

View File

@@ -87,7 +87,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
const RbacRoleBindingSchema = z.union([
z.object({
role: z.enum(['edit', 'view', 'create', 'delete', 'run']),
role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']),
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
name: z.string().min(1).optional(),
}),
@@ -110,7 +110,6 @@ const ProjectSpecSchema = z.object({
llmProvider: z.string().optional(),
llmModel: z.string().optional(),
servers: z.array(z.string()).default([]),
members: z.array(z.string().email()).default([]),
});
const ApplyConfigSchema = z.object({
@@ -246,7 +245,7 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
// Apply projects (send full spec including servers/members)
// Apply projects (send full spec including servers)
for (const project of config.projects) {
try {
const existing = await findByName(client, 'projects', project.name);

View File

@@ -196,10 +196,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--llm-provider <name>', 'LLM provider name')
.option('--llm-model <name>', 'LLM model name')
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
@@ -207,10 +206,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
description: opts.description,
proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.llmProvider) body.llmProvider = opts.llmProvider;
if (opts.llmModel) body.llmModel = opts.llmModel;
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
if (opts.server.length > 0) body.servers = opts.server;
if (opts.member.length > 0) body.members = opts.member;
try {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);

View File

@@ -162,17 +162,6 @@ function formatProjectDetail(project: Record<string, unknown>): string {
}
}
// Members section (no role — all permissions are in RBAC)
const members = project.members as Array<{ user: { email: string } }> | undefined;
if (members && members.length > 0) {
lines.push('');
lines.push('Members:');
lines.push(' EMAIL');
for (const m of members) {
lines.push(` ${m.user.email}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`);

View File

@@ -24,7 +24,6 @@ interface ProjectRow {
proxyMode: string;
ownerId: string;
servers?: Array<{ server: { name: string } }>;
members?: Array<{ user: { email: string }; role: string }>;
}
interface SecretRow {
@@ -85,7 +84,6 @@ const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
{ header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 30 },
{ header: 'ID', key: 'id' },
];

View File

@@ -0,0 +1,47 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface ProjectOpsDeps {
client: ApiClient;
log: (...args: string[]) => void;
getProject: () => string | undefined;
}
function requireProject(deps: ProjectOpsDeps): string {
const project = deps.getProject();
if (!project) {
deps.log('Error: --project <name> is required for this command.');
process.exitCode = 1;
throw new Error('--project required');
}
return project;
}
export function createAttachServerCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('attach-server')
.description('Attach a server to a project (requires --project)')
.argument('<server-name>', 'Server name to attach')
.action(async (serverName: string) => {
const projectName = requireProject(deps);
const projectId = await resolveNameOrId(client, 'projects', projectName);
await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
log(`server '${serverName}' attached to project '${projectName}'`);
});
}
export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('detach-server')
.description('Detach a server from a project (requires --project)')
.argument('<server-name>', 'Server name to detach')
.action(async (serverName: string) => {
const projectName = requireProject(deps);
const projectId = await resolveNameOrId(client, 'projects', projectName);
await client.delete(`/api/v1/projects/${projectId}/servers/${serverName}`);
log(`server '${serverName}' detached from project '${projectName}'`);
});
}

View File

@@ -12,6 +12,7 @@ import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
@@ -24,7 +25,8 @@ export function createProgram(): Command {
.version(APP_VERSION, '-v, --version')
.enablePositionalOptions()
.option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
.option('--project <name>', 'Target project for project commands');
program.addCommand(createStatusCommand());
program.addCommand(createLoginCommand());
@@ -126,6 +128,14 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
const projectOpsDeps = {
client,
log: (...args: string[]) => console.log(...args),
getProject: () => program.opts().project as string | undefined,
};
program.addCommand(createAttachServerCommand(projectOpsDeps));
program.addCommand(createDetachServerCommand(projectOpsDeps));
return program;
}

View File

@@ -326,7 +326,7 @@ rbacBindings:
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies projects with servers and members', async () => {
it('applies projects with servers', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
projects:
@@ -338,9 +338,6 @@ projects:
servers:
- my-grafana
- my-ha
members:
- alice@test.com
- bob@test.com
`);
const cmd = createApplyCommand({ client, log });
@@ -352,7 +349,6 @@ projects:
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],
members: ['alice@test.com', 'bob@test.com'],
}));
expect(output.join('\n')).toContain('Created project: smart-home');

View File

@@ -181,7 +181,6 @@ describe('get command', () => {
proxyMode: 'filtered',
ownerId: 'usr-1',
servers: [{ server: { name: 'grafana' } }],
members: [{ user: { email: 'a@b.com' }, role: 'admin' }, { user: { email: 'c@d.com' }, role: 'member' }],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
@@ -189,11 +188,9 @@ describe('get command', () => {
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('MEMBERS');
expect(text).toContain('smart-home');
expect(text).toContain('filtered');
expect(text).toContain('1');
expect(text).toContain('2');
});
it('displays mixed resource and operation bindings', async () => {

View File

@@ -30,8 +30,8 @@ describe('project with new fields', () => {
'project', 'smart-home',
'-d', 'Smart home project',
'--proxy-mode', 'filtered',
'--llm-provider', 'gemini-cli',
'--llm-model', 'gemini-2.0-flash',
'--proxy-mode-llm-provider', 'gemini-cli',
'--proxy-mode-llm-model', 'gemini-2.0-flash',
'--server', 'my-grafana',
'--server', 'my-ha',
], { from: 'user' });
@@ -46,20 +46,6 @@ describe('project with new fields', () => {
}));
});
it('creates project with members', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'project', 'team-project',
'--member', 'alice@test.com',
'--member', 'bob@test.com',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'team-project',
members: ['alice@test.com', 'bob@test.com'],
}));
});
it('defaults proxy mode to direct', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
@@ -71,7 +57,7 @@ describe('project with new fields', () => {
});
describe('get projects shows new columns', () => {
it('shows MODE, SERVERS, MEMBERS columns', async () => {
it('shows MODE and SERVERS columns', async () => {
const deps = {
output: [] as string[],
fetchResource: vi.fn(async () => [{
@@ -81,7 +67,6 @@ describe('project with new fields', () => {
proxyMode: 'filtered',
ownerId: 'user-1',
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
members: [{ user: { email: 'alice@test.com' } }],
}]),
log: (...args: string[]) => deps.output.push(args.join(' ')),
};
@@ -91,13 +76,12 @@ describe('project with new fields', () => {
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('MEMBERS');
expect(text).toContain('smart-home');
});
});
describe('describe project shows full detail', () => {
it('shows servers and members', async () => {
it('shows servers and proxy config', async () => {
const deps = {
output: [] as string[],
client: mockClient(),
@@ -113,10 +97,6 @@ describe('project with new fields', () => {
{ server: { name: 'my-grafana' } },
{ server: { name: 'my-ha' } },
],
members: [
{ user: { email: 'alice@test.com' } },
{ user: { email: 'bob@test.com' } },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
})),
@@ -131,8 +111,6 @@ describe('project with new fields', () => {
expect(text).toContain('gemini-cli');
expect(text).toContain('my-grafana');
expect(text).toContain('my-ha');
expect(text).toContain('alice@test.com');
expect(text).toContain('bob@test.com');
});
});
});