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:
@@ -5,7 +5,7 @@ _mcpctl() {
|
|||||||
local cur prev words cword
|
local cur prev words cword
|
||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
local commands="status login logout config get describe delete logs create edit apply patch backup restore approve console"
|
local commands="status login logout config get describe delete logs create edit apply patch backup restore approve console cache"
|
||||||
local project_commands="get describe delete logs create edit attach-server detach-server"
|
local project_commands="get describe delete logs create edit attach-server detach-server"
|
||||||
local global_opts="-v --version --daemon-url --direct -p --project -h --help"
|
local global_opts="-v --version --daemon-url --direct -p --project -h --help"
|
||||||
local resources="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all"
|
local resources="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all"
|
||||||
@@ -185,7 +185,7 @@ _mcpctl() {
|
|||||||
COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
project)
|
project)
|
||||||
COMPREPLY=($(compgen -W "-d --description --proxy-mode --proxy-model --prompt --gated --no-gated --server --force -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --gated --no-gated --server --force -h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
user)
|
user)
|
||||||
COMPREPLY=($(compgen -W "--password --name --force -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--password --name --force -h --help" -- "$cur"))
|
||||||
@@ -281,6 +281,24 @@ _mcpctl() {
|
|||||||
COMPREPLY=($(compgen -W "--stdin-mcp --audit -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--stdin-mcp --audit -h --help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
|
cache)
|
||||||
|
local cache_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
||||||
|
if [[ -z "$cache_sub" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "stats clear help" -- "$cur"))
|
||||||
|
else
|
||||||
|
case "$cache_sub" in
|
||||||
|
stats)
|
||||||
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
|
;;
|
||||||
|
clear)
|
||||||
|
COMPREPLY=($(compgen -W "--older-than -y --yes -h --help" -- "$cur"))
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
return ;;
|
||||||
help)
|
help)
|
||||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# Erase any stale completions from previous versions
|
# Erase any stale completions from previous versions
|
||||||
complete -c mcpctl -e
|
complete -c mcpctl -e
|
||||||
|
|
||||||
set -l commands status login logout config get describe delete logs create edit apply patch backup restore approve console
|
set -l commands status login logout config get describe delete logs create edit apply patch backup restore approve console cache
|
||||||
set -l project_commands get describe delete logs create edit attach-server detach-server
|
set -l project_commands get describe delete logs create edit attach-server detach-server
|
||||||
|
|
||||||
# Disable file completions by default
|
# Disable file completions by default
|
||||||
@@ -231,6 +231,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_
|
|||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore mcpctl configuration from a backup file'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore mcpctl configuration from a backup file'
|
||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)'
|
||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache'
|
||||||
|
|
||||||
# Project-scoped commands (with --project)
|
# Project-scoped commands (with --project)
|
||||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)'
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)'
|
||||||
@@ -313,7 +314,6 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l force -d 'Update
|
|||||||
|
|
||||||
# create project options
|
# create project options
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -s d -l description -d 'Project description' -x
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -s d -l description -d 'Project description' -x
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l proxy-mode -d 'Proxy mode (direct, filtered)' -x
|
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l proxy-model -d 'Plugin name (default, content-pipeline, gate, none)' -x
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l proxy-model -d 'Plugin name (default, content-pipeline, gate, none)' -x
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l prompt -d 'Project-level prompt / instructions for the LLM' -x
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l prompt -d 'Project-level prompt / instructions for the LLM' -x
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l gated -d '[deprecated: use --proxy-model default]'
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l gated -d '[deprecated: use --proxy-model default]'
|
||||||
@@ -353,6 +353,15 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content -
|
|||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content-file -d 'Read prompt content from file' -rF
|
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content-file -d 'Read prompt content from file' -rF
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
|
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
|
||||||
|
|
||||||
|
# cache subcommands
|
||||||
|
set -l cache_cmds stats clear
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from cache; and not __fish_seen_subcommand_from $cache_cmds" -a stats -d 'Show cache statistics'
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from cache; and not __fish_seen_subcommand_from $cache_cmds" -a clear -d 'Clear cache entries'
|
||||||
|
|
||||||
|
# cache clear options
|
||||||
|
complete -c mcpctl -n "__mcpctl_subcmd_active cache clear" -l older-than -d 'Clear entries older than N days' -x
|
||||||
|
complete -c mcpctl -n "__mcpctl_subcmd_active cache clear" -s y -l yes -d 'Skip confirmation'
|
||||||
|
|
||||||
# status options
|
# status options
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x
|
complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ const ProjectSpecSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().default(''),
|
description: z.string().default(''),
|
||||||
prompt: z.string().max(10000).default(''),
|
prompt: z.string().max(10000).default(''),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
|
||||||
proxyModel: z.string().optional(),
|
proxyModel: z.string().optional(),
|
||||||
gated: z.boolean().optional(),
|
gated: z.boolean().optional(),
|
||||||
llmProvider: z.string().optional(),
|
llmProvider: z.string().optional(),
|
||||||
|
|||||||
@@ -225,7 +225,6 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.description('Create a project')
|
.description('Create a project')
|
||||||
.argument('<name>', 'Project name')
|
.argument('<name>', 'Project name')
|
||||||
.option('-d, --description <text>', 'Project description', '')
|
.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('--proxy-model <name>', 'Plugin name (default, content-pipeline, gate, none)')
|
||||||
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
||||||
.option('--gated', '[deprecated: use --proxy-model default]')
|
.option('--gated', '[deprecated: use --proxy-model default]')
|
||||||
@@ -236,7 +235,6 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
name,
|
name,
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
proxyMode: opts.proxyMode ?? 'direct',
|
|
||||||
};
|
};
|
||||||
if (opts.prompt) body.prompt = opts.prompt;
|
if (opts.prompt) body.prompt = opts.prompt;
|
||||||
if (opts.proxyModel) {
|
if (opts.proxyModel) {
|
||||||
|
|||||||
@@ -143,8 +143,7 @@ function formatProjectDetail(
|
|||||||
lines.push(`${pad('Name:')}${project.name}`);
|
lines.push(`${pad('Name:')}${project.name}`);
|
||||||
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
|
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
|
||||||
|
|
||||||
// Plugin & proxy config
|
// Plugin config
|
||||||
const proxyMode = project.proxyMode as string | undefined;
|
|
||||||
const proxyModel = (project.proxyModel as string | undefined) || 'default';
|
const proxyModel = (project.proxyModel as string | undefined) || 'default';
|
||||||
const llmProvider = project.llmProvider as string | undefined;
|
const llmProvider = project.llmProvider as string | undefined;
|
||||||
const llmModel = project.llmModel as string | undefined;
|
const llmModel = project.llmModel as string | undefined;
|
||||||
@@ -152,7 +151,6 @@ function formatProjectDetail(
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('Plugin Config:');
|
lines.push('Plugin Config:');
|
||||||
lines.push(` ${pad('Plugin:', 18)}${proxyModel}`);
|
lines.push(` ${pad('Plugin:', 18)}${proxyModel}`);
|
||||||
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
|
|
||||||
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
|
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
|
||||||
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);
|
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ interface ProjectRow {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
proxyMode: string;
|
|
||||||
proxyModel: string;
|
proxyModel: string;
|
||||||
gated?: boolean;
|
gated?: boolean;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -86,7 +85,6 @@ interface RbacRow {
|
|||||||
|
|
||||||
const projectColumns: Column<ProjectRow>[] = [
|
const projectColumns: Column<ProjectRow>[] = [
|
||||||
{ header: 'NAME', key: 'name' },
|
{ header: 'NAME', key: 'name' },
|
||||||
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
|
||||||
{ header: 'PLUGIN', key: (r) => r.proxyModel || 'default', width: 18 },
|
{ header: 'PLUGIN', key: (r) => r.proxyModel || 'default', width: 18 },
|
||||||
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
||||||
{ header: 'DESCRIPTION', key: 'description', width: 30 },
|
{ header: 'DESCRIPTION', key: 'description', width: 30 },
|
||||||
|
|||||||
@@ -332,7 +332,6 @@ rbacBindings:
|
|||||||
projects:
|
projects:
|
||||||
- name: smart-home
|
- name: smart-home
|
||||||
description: Home automation
|
description: Home automation
|
||||||
proxyMode: filtered
|
|
||||||
llmProvider: gemini-cli
|
llmProvider: gemini-cli
|
||||||
llmModel: gemini-2.0-flash
|
llmModel: gemini-2.0-flash
|
||||||
servers:
|
servers:
|
||||||
@@ -345,7 +344,6 @@ projects:
|
|||||||
|
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||||
name: 'smart-home',
|
name: 'smart-home',
|
||||||
proxyMode: 'filtered',
|
|
||||||
llmProvider: 'gemini-cli',
|
llmProvider: 'gemini-cli',
|
||||||
llmModel: 'gemini-2.0-flash',
|
llmModel: 'gemini-2.0-flash',
|
||||||
servers: ['my-grafana', 'my-ha'],
|
servers: ['my-grafana', 'my-ha'],
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ describe('create command', () => {
|
|||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
||||||
name: 'my-project',
|
name: 'my-project',
|
||||||
description: 'A test project',
|
description: 'A test project',
|
||||||
proxyMode: 'direct',
|
|
||||||
});
|
});
|
||||||
expect(output.join('\n')).toContain("project 'test' created");
|
expect(output.join('\n')).toContain("project 'test' created");
|
||||||
});
|
});
|
||||||
@@ -186,7 +185,6 @@ describe('create command', () => {
|
|||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
||||||
name: 'minimal',
|
name: 'minimal',
|
||||||
description: '',
|
description: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +193,7 @@ describe('create command', () => {
|
|||||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
|
||||||
const cmd = createCreateCommand({ client, log });
|
const cmd = createCreateCommand({ client, log });
|
||||||
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
|
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");
|
expect(output.join('\n')).toContain("project 'my-proj' updated");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ describe('describe command', () => {
|
|||||||
description: 'A gated project',
|
description: 'A gated project',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
proxyModel: 'default',
|
proxyModel: 'default',
|
||||||
proxyMode: 'direct',
|
|
||||||
createdAt: '2025-01-01',
|
createdAt: '2025-01-01',
|
||||||
});
|
});
|
||||||
const cmd = createDescribeCommand(deps);
|
const cmd = createDescribeCommand(deps);
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ describe('get command', () => {
|
|||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'smart-home',
|
name: 'smart-home',
|
||||||
description: 'Home automation',
|
description: 'Home automation',
|
||||||
proxyMode: 'filtered',
|
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
servers: [{ server: { name: 'grafana' } }],
|
servers: [{ server: { name: 'grafana' } }],
|
||||||
}]);
|
}]);
|
||||||
@@ -187,10 +186,8 @@ describe('get command', () => {
|
|||||||
await cmd.parseAsync(['node', 'test', 'projects']);
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
||||||
|
|
||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('MODE');
|
|
||||||
expect(text).toContain('SERVERS');
|
expect(text).toContain('SERVERS');
|
||||||
expect(text).toContain('smart-home');
|
expect(text).toContain('smart-home');
|
||||||
expect(text).toContain('filtered');
|
|
||||||
expect(text).toContain('1');
|
expect(text).toContain('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +338,6 @@ describe('get command', () => {
|
|||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
description: '',
|
description: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
@@ -362,7 +358,6 @@ describe('get command', () => {
|
|||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
description: '',
|
description: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
@@ -381,7 +376,6 @@ describe('get command', () => {
|
|||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'tools',
|
name: 'tools',
|
||||||
description: '',
|
description: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: false,
|
gated: false,
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
@@ -400,7 +394,6 @@ describe('get command', () => {
|
|||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'custom',
|
name: 'custom',
|
||||||
description: '',
|
description: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: 'gate',
|
proxyModel: 'gate',
|
||||||
gated: true,
|
gated: true,
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
|
|||||||
@@ -24,12 +24,11 @@ describe('project with new fields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('create project with enhanced options', () => {
|
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 });
|
const cmd = createCreateCommand({ client, log });
|
||||||
await cmd.parseAsync([
|
await cmd.parseAsync([
|
||||||
'project', 'smart-home',
|
'project', 'smart-home',
|
||||||
'-d', 'Smart home project',
|
'-d', 'Smart home project',
|
||||||
'--proxy-mode', 'filtered',
|
|
||||||
'--server', 'my-grafana',
|
'--server', 'my-grafana',
|
||||||
'--server', 'my-ha',
|
'--server', 'my-ha',
|
||||||
], { from: 'user' });
|
], { from: 'user' });
|
||||||
@@ -37,30 +36,19 @@ describe('project with new fields', () => {
|
|||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||||
name: 'smart-home',
|
name: 'smart-home',
|
||||||
description: 'Smart home project',
|
description: 'Smart home project',
|
||||||
proxyMode: 'filtered',
|
|
||||||
servers: ['my-grafana', 'my-ha'],
|
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', () => {
|
describe('get projects shows new columns', () => {
|
||||||
it('shows MODE and SERVERS columns', async () => {
|
it('shows SERVERS column', async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
output: [] as string[],
|
output: [] as string[],
|
||||||
fetchResource: vi.fn(async () => [{
|
fetchResource: vi.fn(async () => [{
|
||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'smart-home',
|
name: 'smart-home',
|
||||||
description: 'Test',
|
description: 'Test',
|
||||||
proxyMode: 'filtered',
|
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
||||||
}]),
|
}]),
|
||||||
@@ -70,14 +58,13 @@ describe('project with new fields', () => {
|
|||||||
await cmd.parseAsync(['node', 'test', 'projects']);
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
||||||
|
|
||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('MODE');
|
|
||||||
expect(text).toContain('SERVERS');
|
expect(text).toContain('SERVERS');
|
||||||
expect(text).toContain('smart-home');
|
expect(text).toContain('smart-home');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('describe project shows full detail', () => {
|
describe('describe project shows full detail', () => {
|
||||||
it('shows servers and proxy config', async () => {
|
it('shows servers and LLM config', async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
output: [] as string[],
|
output: [] as string[],
|
||||||
client: mockClient(),
|
client: mockClient(),
|
||||||
@@ -85,7 +72,6 @@ describe('project with new fields', () => {
|
|||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'smart-home',
|
name: 'smart-home',
|
||||||
description: 'Smart home',
|
description: 'Smart home',
|
||||||
proxyMode: 'filtered',
|
|
||||||
llmProvider: 'gemini-cli',
|
llmProvider: 'gemini-cli',
|
||||||
llmModel: 'gemini-2.0-flash',
|
llmModel: 'gemini-2.0-flash',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
@@ -103,7 +89,6 @@ describe('project with new fields', () => {
|
|||||||
|
|
||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('=== Project: smart-home ===');
|
expect(text).toContain('=== Project: smart-home ===');
|
||||||
expect(text).toContain('filtered');
|
|
||||||
expect(text).toContain('gemini-cli');
|
expect(text).toContain('gemini-cli');
|
||||||
expect(text).toContain('my-grafana');
|
expect(text).toContain('my-grafana');
|
||||||
expect(text).toContain('my-ha');
|
expect(text).toContain('my-ha');
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Remove proxyMode column (redundant — all traffic goes through mcplocal proxy)
|
||||||
|
ALTER TABLE "Project" DROP COLUMN IF EXISTS "proxyMode";
|
||||||
@@ -173,7 +173,6 @@ model Project {
|
|||||||
name String @unique
|
name String @unique
|
||||||
description String @default("")
|
description String @default("")
|
||||||
prompt String @default("")
|
prompt String @default("")
|
||||||
proxyMode String @default("direct")
|
|
||||||
proxyModel String @default("")
|
proxyModel String @default("")
|
||||||
gated Boolean @default(true)
|
gated Boolean @default(true)
|
||||||
llmProvider String?
|
llmProvider String?
|
||||||
|
|||||||
@@ -506,23 +506,16 @@ describe('ProjectServer', () => {
|
|||||||
// ── Project new fields ──
|
// ── Project new fields ──
|
||||||
|
|
||||||
describe('Project new fields', () => {
|
describe('Project new fields', () => {
|
||||||
it('defaults proxyMode to direct', async () => {
|
it('stores llmProvider, llmModel', async () => {
|
||||||
const project = await createProject();
|
|
||||||
expect(project.proxyMode).toBe('direct');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores proxyMode, llmProvider, llmModel', async () => {
|
|
||||||
const user = await createUser();
|
const user = await createUser();
|
||||||
const project = await prisma.project.create({
|
const project = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'filtered-project',
|
name: 'filtered-project',
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
proxyMode: 'filtered',
|
|
||||||
llmProvider: 'gemini-cli',
|
llmProvider: 'gemini-cli',
|
||||||
llmModel: 'gemini-2.0-flash',
|
llmModel: 'gemini-2.0-flash',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(project.proxyMode).toBe('filtered');
|
|
||||||
expect(project.llmProvider).toBe('gemini-cli');
|
expect(project.llmProvider).toBe('gemini-cli');
|
||||||
expect(project.llmModel).toBe('gemini-2.0-flash');
|
expect(project.llmModel).toBe('gemini-2.0-flash');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ export async function bootstrapSystemProject(prisma: PrismaClient): Promise<void
|
|||||||
name: SYSTEM_PROJECT_NAME,
|
name: SYSTEM_PROJECT_NAME,
|
||||||
description: 'System prompts for mcpctl gating and session management',
|
description: 'System prompts for mcpctl gating and session management',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
gated: false,
|
gated: false,
|
||||||
ownerId: systemUser.id,
|
ownerId: systemUser.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ async function main(): Promise<void> {
|
|||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||||
serverService.setInstanceService(instanceService);
|
serverService.setInstanceService(instanceService);
|
||||||
const secretService = new SecretService(secretRepo);
|
const secretService = new SecretService(secretRepo);
|
||||||
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo);
|
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||||
const auditLogService = new AuditLogService(auditLogRepo);
|
const auditLogService = new AuditLogService(auditLogRepo);
|
||||||
const auditEventService = new AuditEventService(auditEventRepo);
|
const auditEventService = new AuditEventService(auditEventRepo);
|
||||||
const metricsCollector = new MetricsCollector();
|
const metricsCollector = new MetricsCollector();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface IProjectRepository {
|
|||||||
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
|
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
|
||||||
findById(id: string): Promise<ProjectWithRelations | null>;
|
findById(id: string): Promise<ProjectWithRelations | null>;
|
||||||
findByName(name: 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>;
|
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
setServers(projectId: string, serverIds: 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>;
|
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> = {
|
const createData: Record<string, unknown> = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
ownerId: data.ownerId,
|
ownerId: data.ownerId,
|
||||||
proxyMode: data.proxyMode,
|
|
||||||
};
|
};
|
||||||
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
|
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
|
||||||
if (data.proxyModel !== undefined) createData['proxyModel'] = data.proxyModel;
|
if (data.proxyModel !== undefined) createData['proxyModel'] = data.proxyModel;
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ export class BackupService {
|
|||||||
projects = allProjects.map((proj) => ({
|
projects = allProjects.map((proj) => ({
|
||||||
name: proj.name,
|
name: proj.name,
|
||||||
description: proj.description,
|
description: proj.description,
|
||||||
proxyMode: proj.proxyMode,
|
|
||||||
proxyModel: proj.proxyModel,
|
proxyModel: proj.proxyModel,
|
||||||
llmProvider: proj.llmProvider,
|
llmProvider: proj.llmProvider,
|
||||||
llmModel: proj.llmModel,
|
llmModel: proj.llmModel,
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ export class RestoreService {
|
|||||||
}
|
}
|
||||||
// overwrite
|
// overwrite
|
||||||
const updateData: Record<string, unknown> = { description: project.description };
|
const updateData: Record<string, unknown> = { description: project.description };
|
||||||
if (project.proxyMode) updateData['proxyMode'] = project.proxyMode;
|
|
||||||
if (project.proxyModel) updateData['proxyModel'] = project.proxyModel;
|
if (project.proxyModel) updateData['proxyModel'] = project.proxyModel;
|
||||||
if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider;
|
if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider;
|
||||||
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
||||||
@@ -271,11 +270,10 @@ export class RestoreService {
|
|||||||
continue;
|
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,
|
name: project.name,
|
||||||
description: project.description,
|
description: project.description,
|
||||||
ownerId: 'system',
|
ownerId: 'system',
|
||||||
proxyMode: project.proxyMode ?? 'direct',
|
|
||||||
};
|
};
|
||||||
if (project.proxyModel) projectCreateData.proxyModel = project.proxyModel;
|
if (project.proxyModel) projectCreateData.proxyModel = project.proxyModel;
|
||||||
if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider;
|
if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider;
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import type { McpServer } from '@prisma/client';
|
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
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 { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
|
||||||
import { NotFoundError, ConflictError } from './mcp-server.service.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';
|
import type { McpConfig } from './mcp-config-generator.js';
|
||||||
|
|
||||||
export class ProjectService {
|
export class ProjectService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly projectRepo: IProjectRepository,
|
private readonly projectRepo: IProjectRepository,
|
||||||
private readonly serverRepo: IMcpServerRepository,
|
private readonly serverRepo: IMcpServerRepository,
|
||||||
private readonly secretRepo: ISecretRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
|
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
|
||||||
@@ -55,7 +51,6 @@ export class ProjectService {
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
prompt: data.prompt,
|
prompt: data.prompt,
|
||||||
ownerId,
|
ownerId,
|
||||||
proxyMode: data.proxyMode,
|
|
||||||
proxyModel: data.proxyModel,
|
proxyModel: data.proxyModel,
|
||||||
gated: data.gated,
|
gated: data.gated,
|
||||||
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
|
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
|
||||||
@@ -80,7 +75,6 @@ export class ProjectService {
|
|||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (data.description !== undefined) updateData['description'] = data.description;
|
if (data.description !== undefined) updateData['description'] = data.description;
|
||||||
if (data.prompt !== undefined) updateData['prompt'] = data.prompt;
|
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.proxyModel !== undefined) updateData['proxyModel'] = data.proxyModel;
|
||||||
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
|
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
|
||||||
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
|
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
|
||||||
@@ -110,8 +104,7 @@ export class ProjectService {
|
|||||||
async generateMcpConfig(idOrName: string): Promise<McpConfig> {
|
async generateMcpConfig(idOrName: string): Promise<McpConfig> {
|
||||||
const project = await this.resolveAndGet(idOrName);
|
const project = await this.resolveAndGet(idOrName);
|
||||||
|
|
||||||
if (project.proxyMode === 'filtered') {
|
// All traffic goes through mcplocal proxy — single entry
|
||||||
// Single entry pointing at mcplocal proxy
|
|
||||||
return {
|
return {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
[project.name]: {
|
[project.name]: {
|
||||||
@@ -121,20 +114,6 @@ export class ProjectService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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> {
|
async addServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
|
||||||
const project = await this.resolveAndGet(idOrName);
|
const project = await this.resolveAndGet(idOrName);
|
||||||
const server = await this.serverRepo.findByName(serverName);
|
const server = await this.serverRepo.findByName(serverName);
|
||||||
|
|||||||
@@ -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'),
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||||
description: z.string().max(1000).default(''),
|
description: z.string().max(1000).default(''),
|
||||||
prompt: z.string().max(10000).default(''),
|
prompt: z.string().max(10000).default(''),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
|
||||||
proxyModel: z.string().max(100).default(''),
|
proxyModel: z.string().max(100).default(''),
|
||||||
gated: z.boolean().default(true),
|
gated: z.boolean().default(true),
|
||||||
llmProvider: z.string().max(100).optional(),
|
llmProvider: z.string().max(100).optional(),
|
||||||
@@ -13,15 +12,13 @@ export const CreateProjectSchema = z.object({
|
|||||||
serverOverrides: z.record(z.string(), z.object({
|
serverOverrides: z.record(z.string(), z.object({
|
||||||
proxyModel: z.string().optional(),
|
proxyModel: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
}).refine(
|
// Backward compat: accept but ignore proxyMode from old configs
|
||||||
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
|
proxyMode: z.string().optional(),
|
||||||
{ message: 'llmProvider is required when proxyMode is "filtered"' },
|
}).transform(({ proxyMode: _ignored, ...rest }) => rest);
|
||||||
);
|
|
||||||
|
|
||||||
export const UpdateProjectSchema = z.object({
|
export const UpdateProjectSchema = z.object({
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
prompt: z.string().max(10000).optional(),
|
prompt: z.string().max(10000).optional(),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).optional(),
|
|
||||||
proxyModel: z.string().max(100).optional(),
|
proxyModel: z.string().max(100).optional(),
|
||||||
gated: z.boolean().optional(),
|
gated: z.boolean().optional(),
|
||||||
llmProvider: z.string().max(100).nullable().optional(),
|
llmProvider: z.string().max(100).nullable().optional(),
|
||||||
@@ -30,7 +27,9 @@ export const UpdateProjectSchema = z.object({
|
|||||||
serverOverrides: z.record(z.string(), z.object({
|
serverOverrides: z.record(z.string(), z.object({
|
||||||
proxyModel: z.string().optional(),
|
proxyModel: z.string().optional(),
|
||||||
})).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 CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const mockSecrets = [
|
|||||||
|
|
||||||
const mockProjects = [
|
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(),
|
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
|
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
|
||||||
},
|
},
|
||||||
@@ -217,7 +217,6 @@ describe('BackupService', () => {
|
|||||||
it('includes enriched projects with server names', async () => {
|
it('includes enriched projects with server names', async () => {
|
||||||
const bundle = await backupService.createBackup();
|
const bundle = await backupService.createBackup();
|
||||||
const proj = bundle.projects[0]!;
|
const proj = bundle.projects[0]!;
|
||||||
expect(proj.proxyMode).toBe('direct');
|
|
||||||
expect(proj.serverNames).toEqual(['github']);
|
expect(proj.serverNames).toEqual(['github']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,7 +321,7 @@ describe('RestoreService', () => {
|
|||||||
{ name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] },
|
{ name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] },
|
||||||
],
|
],
|
||||||
projects: [
|
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(result.projectsCreated).toBe(1);
|
||||||
expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
name: 'test-proj',
|
name: 'test-proj',
|
||||||
proxyMode: 'filtered',
|
|
||||||
llmProvider: 'openai',
|
llmProvider: 'openai',
|
||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { registerProjectRoutes } from '../src/routes/projects.js';
|
|||||||
import { ProjectService } from '../src/services/project.service.js';
|
import { ProjectService } from '../src/services/project.service.js';
|
||||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.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;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
|||||||
name: 'test-project',
|
name: 'test-project',
|
||||||
description: '',
|
description: '',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
proxyMode: 'direct',
|
|
||||||
prompt: '',
|
prompt: '',
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
@@ -39,7 +38,6 @@ function mockProjectRepo(): IProjectRepository {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
ownerId: data.ownerId,
|
ownerId: data.ownerId,
|
||||||
proxyMode: data.proxyMode,
|
|
||||||
})),
|
})),
|
||||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||||
delete: vi.fn(async () => {}),
|
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 () => {
|
afterEach(async () => {
|
||||||
if (app) await app.close();
|
if (app) await app.close();
|
||||||
});
|
});
|
||||||
@@ -78,7 +65,7 @@ afterEach(async () => {
|
|||||||
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
|
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false });
|
||||||
app.setErrorHandler(errorHandler);
|
app.setErrorHandler(errorHandler);
|
||||||
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo(), mockSecretRepo());
|
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo());
|
||||||
registerProjectRoutes(app, service);
|
registerProjectRoutes(app, service);
|
||||||
return app.ready();
|
return app.ready();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { ProjectService } from '../src/services/project.service.js';
|
import { ProjectService } from '../src/services/project.service.js';
|
||||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.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';
|
import type { McpServer } from '@prisma/client';
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||||
@@ -11,7 +11,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
|||||||
name: 'test-project',
|
name: 'test-project',
|
||||||
description: '',
|
description: '',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
llmProvider: null,
|
llmProvider: null,
|
||||||
@@ -57,7 +56,6 @@ function mockProjectRepo(): IProjectRepository {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
ownerId: data.ownerId,
|
ownerId: data.ownerId,
|
||||||
proxyMode: data.proxyMode,
|
|
||||||
llmProvider: data.llmProvider ?? null,
|
llmProvider: data.llmProvider ?? null,
|
||||||
llmModel: data.llmModel ?? 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', () => {
|
describe('ProjectService', () => {
|
||||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||||
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
|
||||||
let service: ProjectService;
|
let service: ProjectService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
projectRepo = mockProjectRepo();
|
projectRepo = mockProjectRepo();
|
||||||
serverRepo = mockServerRepo();
|
serverRepo = mockServerRepo();
|
||||||
secretRepo = mockSecretRepo();
|
service = new ProjectService(projectRepo, serverRepo);
|
||||||
service = new ProjectService(projectRepo, serverRepo, secretRepo);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
@@ -149,27 +134,6 @@ describe('ProjectService', () => {
|
|||||||
expect(result.servers).toHaveLength(2);
|
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 () => {
|
it('throws NotFoundError when server name resolution fails', async () => {
|
||||||
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
||||||
|
|
||||||
@@ -226,13 +190,12 @@ describe('ProjectService', () => {
|
|||||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates proxyMode', async () => {
|
it('updates llmProvider', async () => {
|
||||||
const existing = makeProject({ id: 'proj-1' });
|
const existing = makeProject({ id: 'proj-1' });
|
||||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
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', {
|
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
|
||||||
proxyMode: 'filtered',
|
|
||||||
llmProvider: 'anthropic',
|
llmProvider: 'anthropic',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -297,46 +260,10 @@ describe('ProjectService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('generateMcpConfig', () => {
|
describe('generateMcpConfig', () => {
|
||||||
it('generates direct mode config with STDIO servers', async () => {
|
it('generates single mcplocal proxy entry', async () => {
|
||||||
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
|
||||||
const project = makeProject({
|
const project = makeProject({
|
||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'my-proj',
|
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' } }],
|
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,14 +271,13 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
const config = await service.generateMcpConfig('proj-1');
|
const config = await service.generateMcpConfig('proj-1');
|
||||||
expect(Object.keys(config.mcpServers)).toHaveLength(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 () => {
|
it('resolves by name for mcp-config', async () => {
|
||||||
const project = makeProject({
|
const project = makeProject({
|
||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
name: 'my-proj',
|
name: 'my-proj',
|
||||||
proxyMode: 'direct',
|
|
||||||
servers: [],
|
servers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,27 +285,8 @@ describe('ProjectService', () => {
|
|||||||
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
|
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
|
||||||
|
|
||||||
const config = await service.generateMcpConfig('my-proj');
|
const config = await service.generateMcpConfig('my-proj');
|
||||||
expect(config.mcpServers).toEqual({});
|
expect(Object.keys(config.mcpServers)).toHaveLength(1);
|
||||||
});
|
expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj');
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
|||||||
name: 'homeautomation',
|
name: 'homeautomation',
|
||||||
description: '',
|
description: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
llmProvider: null,
|
llmProvider: null,
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
|||||||
name: 'test-project',
|
name: 'test-project',
|
||||||
description: '',
|
description: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
llmProvider: null,
|
llmProvider: null,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
|||||||
name: 'test-project',
|
name: 'test-project',
|
||||||
description: '',
|
description: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
proxyMode: 'direct',
|
|
||||||
proxyModel: '',
|
proxyModel: '',
|
||||||
gated: true,
|
gated: true,
|
||||||
llmProvider: null,
|
llmProvider: null,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ projects:
|
|||||||
- name: smoke-data
|
- name: smoke-data
|
||||||
description: "Smoke test project with 100 AWS documentation prompt links"
|
description: "Smoke test project with 100 AWS documentation prompt links"
|
||||||
gated: true
|
gated: true
|
||||||
proxyMode: direct
|
|
||||||
|
|
||||||
serverattachments:
|
serverattachments:
|
||||||
- server: smoke-aws-docs
|
- server: smoke-aws-docs
|
||||||
|
|||||||
Reference in New Issue
Block a user