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>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { createGetCommand } from '../../src/commands/get.js';
|
|
import type { GetCommandDeps } from '../../src/commands/get.js';
|
|
|
|
function makeDeps(items: unknown[] = []): GetCommandDeps & { output: string[] } {
|
|
const output: string[] = [];
|
|
return {
|
|
output,
|
|
fetchResource: vi.fn(async () => items),
|
|
log: (...args: string[]) => output.push(args.join(' ')),
|
|
};
|
|
}
|
|
|
|
describe('get command', () => {
|
|
it('lists servers in table format', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'srv-1', name: 'slack', transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null },
|
|
{ id: 'srv-2', name: 'github', transport: 'SSE', packageName: null, dockerImage: 'ghcr.io/github-mcp' },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined);
|
|
expect(deps.output[0]).toContain('NAME');
|
|
expect(deps.output[0]).toContain('TRANSPORT');
|
|
expect(deps.output.join('\n')).toContain('slack');
|
|
expect(deps.output.join('\n')).toContain('github');
|
|
});
|
|
|
|
it('resolves resource aliases', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'srv']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined);
|
|
});
|
|
|
|
it('passes ID when provided', async () => {
|
|
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1', undefined);
|
|
});
|
|
|
|
it('outputs apply-compatible JSON format (multi-doc)', async () => {
|
|
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
|
|
|
|
const parsed = JSON.parse(deps.output[0] ?? '');
|
|
// Array of documents with kind field, internal fields stripped
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
expect(parsed[0].kind).toBe('server');
|
|
expect(parsed[0].name).toBe('slack');
|
|
expect(parsed[0]).not.toHaveProperty('id');
|
|
expect(parsed[0]).not.toHaveProperty('createdAt');
|
|
expect(parsed[0]).not.toHaveProperty('updatedAt');
|
|
expect(parsed[0]).not.toHaveProperty('version');
|
|
});
|
|
|
|
it('outputs apply-compatible YAML format (multi-doc)', async () => {
|
|
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
|
|
const text = deps.output[0];
|
|
expect(text).toContain('kind: server');
|
|
expect(text).toContain('name: slack');
|
|
expect(text).not.toContain('id:');
|
|
expect(text).not.toContain('createdAt:');
|
|
});
|
|
|
|
it('lists instances with correct columns', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'instances']);
|
|
expect(deps.output[0]).toContain('NAME');
|
|
expect(deps.output[0]).toContain('STATUS');
|
|
expect(deps.output.join('\n')).toContain('my-grafana');
|
|
expect(deps.output.join('\n')).toContain('RUNNING');
|
|
});
|
|
|
|
it('shows no results message for empty list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers']);
|
|
expect(deps.output[0]).toContain('No servers found');
|
|
});
|
|
|
|
it('lists users with correct columns (no ROLE column)', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null },
|
|
{ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'users']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined);
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('EMAIL');
|
|
expect(text).toContain('NAME');
|
|
expect(text).not.toContain('ROLE');
|
|
expect(text).toContain('PROVIDER');
|
|
expect(text).toContain('alice@test.com');
|
|
expect(text).toContain('Alice');
|
|
expect(text).toContain('bob@test.com');
|
|
expect(text).toContain('oidc');
|
|
});
|
|
|
|
it('resolves user alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'user']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined);
|
|
});
|
|
|
|
it('lists groups with correct columns', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'grp-1',
|
|
name: 'dev-team',
|
|
description: 'Developers',
|
|
members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }],
|
|
},
|
|
{ id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'groups']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined);
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('NAME');
|
|
expect(text).toContain('MEMBERS');
|
|
expect(text).toContain('DESCRIPTION');
|
|
expect(text).toContain('dev-team');
|
|
expect(text).toContain('2');
|
|
expect(text).toContain('ops-team');
|
|
expect(text).toContain('0');
|
|
});
|
|
|
|
it('resolves group alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'group']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined);
|
|
});
|
|
|
|
it('lists rbac definitions with correct columns', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'rbac-1',
|
|
name: 'admins',
|
|
subjects: [{ kind: 'User', name: 'admin@test.com' }],
|
|
roleBindings: [{ role: 'edit', resource: '*' }],
|
|
},
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined);
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('NAME');
|
|
expect(text).toContain('SUBJECTS');
|
|
expect(text).toContain('BINDINGS');
|
|
expect(text).toContain('admins');
|
|
expect(text).toContain('User:admin@test.com');
|
|
expect(text).toContain('edit:*');
|
|
});
|
|
|
|
it('resolves rbac-definition alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined);
|
|
});
|
|
|
|
it('lists projects with new columns', async () => {
|
|
const deps = makeDeps([{
|
|
id: 'proj-1',
|
|
name: 'smart-home',
|
|
description: 'Home automation',
|
|
ownerId: 'usr-1',
|
|
servers: [{ server: { name: 'grafana' } }],
|
|
}]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('SERVERS');
|
|
expect(text).toContain('smart-home');
|
|
expect(text).toContain('1');
|
|
});
|
|
|
|
it('displays mixed resource and operation bindings', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'rbac-1',
|
|
name: 'admin-access',
|
|
subjects: [{ kind: 'Group', name: 'admin' }],
|
|
roleBindings: [
|
|
{ role: 'edit', resource: '*' },
|
|
{ role: 'run', action: 'logs' },
|
|
{ role: 'run', action: 'backup' },
|
|
],
|
|
},
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('edit:*');
|
|
expect(text).toContain('run>logs');
|
|
expect(text).toContain('run>backup');
|
|
});
|
|
|
|
it('displays name-scoped resource bindings', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'rbac-1',
|
|
name: 'ha-viewer',
|
|
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
|
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
|
},
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('view:servers:my-ha');
|
|
});
|
|
|
|
it('shows no results message for empty users list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'users']);
|
|
expect(deps.output[0]).toContain('No users found');
|
|
});
|
|
|
|
it('shows no results message for empty groups list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'groups']);
|
|
expect(deps.output[0]).toContain('No groups found');
|
|
});
|
|
|
|
it('shows no results message for empty rbac list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
expect(deps.output[0]).toContain('No rbac found');
|
|
});
|
|
|
|
it('lists prompts with project name column', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'p-1', name: 'debug-guide', projectId: 'proj-1', project: { name: 'smart-home' }, createdAt: '2025-01-01T00:00:00Z' },
|
|
{ id: 'p-2', name: 'global-rules', projectId: null, project: null, createdAt: '2025-01-01T00:00:00Z' },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompts']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('NAME');
|
|
expect(text).toContain('PROJECT');
|
|
expect(text).toContain('debug-guide');
|
|
expect(text).toContain('smart-home');
|
|
expect(text).toContain('global-rules');
|
|
expect(text).toContain('(global)');
|
|
});
|
|
|
|
it('lists promptrequests with project name column', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'pr-1', name: 'new-rule', projectId: 'proj-1', project: { name: 'my-project' }, createdBySession: 'sess-abc123def456', createdAt: '2025-01-01T00:00:00Z' },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'promptrequests']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('new-rule');
|
|
expect(text).toContain('my-project');
|
|
expect(text).toContain('sess-abc123d');
|
|
});
|
|
|
|
it('passes --project option to fetchResource', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'smart-home']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'smart-home' });
|
|
});
|
|
|
|
it('does not pass project when --project is not specified', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompts']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined);
|
|
});
|
|
|
|
it('passes --all flag to fetchResource', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompts', '-A']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { all: true });
|
|
});
|
|
|
|
it('passes both --project and --all when both given', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'my-proj', '-A']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'my-proj', all: true });
|
|
});
|
|
|
|
it('resolves prompt alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompt']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined);
|
|
});
|
|
|
|
it('resolves pr alias to promptrequests', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'pr']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('promptrequests', undefined, undefined);
|
|
});
|
|
|
|
it('shows no results message for empty prompts list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'prompts']);
|
|
expect(deps.output[0]).toContain('No prompts found');
|
|
});
|
|
|
|
it('lists projects with PLUGIN column showing resolved proxyModel', async () => {
|
|
const deps = makeDeps([{
|
|
id: 'proj-1',
|
|
name: 'home',
|
|
description: '',
|
|
proxyModel: '',
|
|
gated: true,
|
|
ownerId: 'usr-1',
|
|
servers: [],
|
|
}]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('PLUGIN');
|
|
expect(text).not.toContain('GATED');
|
|
// proxyModel is empty but gated=true, table shows 'default'
|
|
expect(text).toContain('default');
|
|
});
|
|
|
|
it('project JSON output resolves proxyModel from gated=true', async () => {
|
|
const deps = makeDeps([{
|
|
id: 'proj-1',
|
|
name: 'home',
|
|
description: '',
|
|
proxyModel: '',
|
|
gated: true,
|
|
ownerId: 'usr-1',
|
|
servers: [],
|
|
}]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']);
|
|
|
|
const parsed = JSON.parse(deps.output[0] ?? '') as Array<Record<string, unknown>>;
|
|
expect(parsed[0]!.proxyModel).toBe('default');
|
|
expect(parsed[0]).not.toHaveProperty('gated');
|
|
});
|
|
|
|
it('project JSON output resolves proxyModel from gated=false', async () => {
|
|
const deps = makeDeps([{
|
|
id: 'proj-1',
|
|
name: 'tools',
|
|
description: '',
|
|
proxyModel: '',
|
|
gated: false,
|
|
ownerId: 'usr-1',
|
|
servers: [],
|
|
}]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']);
|
|
|
|
const parsed = JSON.parse(deps.output[0] ?? '') as Array<Record<string, unknown>>;
|
|
expect(parsed[0]!.proxyModel).toBe('content-pipeline');
|
|
expect(parsed[0]).not.toHaveProperty('gated');
|
|
});
|
|
|
|
it('project JSON output preserves explicit proxyModel and drops gated', async () => {
|
|
const deps = makeDeps([{
|
|
id: 'proj-1',
|
|
name: 'custom',
|
|
description: '',
|
|
proxyModel: 'gate',
|
|
gated: true,
|
|
ownerId: 'usr-1',
|
|
servers: [],
|
|
}]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']);
|
|
|
|
const parsed = JSON.parse(deps.output[0] ?? '') as Array<Record<string, unknown>>;
|
|
expect(parsed[0]!.proxyModel).toBe('gate');
|
|
expect(parsed[0]).not.toHaveProperty('gated');
|
|
});
|
|
});
|