feat: make proxyModel the primary plugin control field
- proxyModel field now determines both YAML pipeline stages AND plugin
gating behavior ('default'/'gate' = gated, 'content-pipeline' = not)
- Deprecate --gated/--no-gated CLI flags (backward compat preserved:
--no-gated maps to --proxy-model content-pipeline)
- Replace GATED column with PLUGIN in `get projects` output
- Update `describe project` to show "Plugin Config" section
- Unify proxymodel discovery: GET /proxymodels now returns both YAML
pipeline models and TypeScript plugins with type field
- `describe proxymodel gate` shows plugin hooks and extends info
- Update CLI apply schema: gated is now optional (not required)
- Regenerate shell completions
- Tests: proxymodel endpoint (5), smoke tests (8)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -314,10 +314,10 @@ 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-mode -d 'Proxy mode (direct, filtered)' -x
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l proxy-model -d 'ProxyModel pipeline name (e.g. default, subindex)' -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 'Enable gated sessions (default: true)'
|
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 no-gated -d 'Disable gated sessions'
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l no-gated -d '[deprecated: use --proxy-model content-pipeline]'
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l server -d 'Server name (repeat for multiple)' -x
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l server -d 'Server name (repeat for multiple)' -x
|
||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l force -d 'Update if already exists'
|
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l force -d 'Update if already exists'
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const ProjectSpecSchema = z.object({
|
|||||||
prompt: z.string().max(10000).default(''),
|
prompt: z.string().max(10000).default(''),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
||||||
proxyModel: z.string().optional(),
|
proxyModel: z.string().optional(),
|
||||||
gated: z.boolean().default(true),
|
gated: z.boolean().optional(),
|
||||||
llmProvider: z.string().optional(),
|
llmProvider: z.string().optional(),
|
||||||
llmModel: z.string().optional(),
|
llmModel: z.string().optional(),
|
||||||
servers: z.array(z.string()).default([]),
|
servers: z.array(z.string()).default([]),
|
||||||
|
|||||||
@@ -226,10 +226,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.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-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||||
.option('--proxy-model <name>', 'ProxyModel pipeline name (e.g. default, subindex)')
|
.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', 'Enable gated sessions (default: true)')
|
.option('--gated', '[deprecated: use --proxy-model default]')
|
||||||
.option('--no-gated', 'Disable gated sessions')
|
.option('--no-gated', '[deprecated: use --proxy-model content-pipeline]')
|
||||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||||
.option('--force', 'Update if already exists')
|
.option('--force', 'Update if already exists')
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
@@ -239,7 +239,13 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
proxyMode: opts.proxyMode ?? 'direct',
|
proxyMode: opts.proxyMode ?? 'direct',
|
||||||
};
|
};
|
||||||
if (opts.prompt) body.prompt = opts.prompt;
|
if (opts.prompt) body.prompt = opts.prompt;
|
||||||
if (opts.proxyModel) body.proxyModel = opts.proxyModel;
|
if (opts.proxyModel) {
|
||||||
|
body.proxyModel = opts.proxyModel;
|
||||||
|
} else if (opts.gated === false) {
|
||||||
|
// Backward compat: --no-gated → proxyModel: content-pipeline
|
||||||
|
body.proxyModel = 'content-pipeline';
|
||||||
|
}
|
||||||
|
// Pass gated for backward compat with older mcpd
|
||||||
if (opts.gated !== undefined) body.gated = opts.gated as boolean;
|
if (opts.gated !== undefined) body.gated = opts.gated as boolean;
|
||||||
if (opts.server.length > 0) body.servers = opts.server;
|
if (opts.server.length > 0) body.servers = opts.server;
|
||||||
|
|
||||||
|
|||||||
@@ -142,21 +142,19 @@ function formatProjectDetail(
|
|||||||
lines.push(`=== Project: ${project.name} ===`);
|
lines.push(`=== Project: ${project.name} ===`);
|
||||||
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}`);
|
||||||
lines.push(`${pad('Gated:')}${project.gated ? 'yes' : 'no'}`);
|
|
||||||
|
|
||||||
// Proxy config section
|
// Plugin & proxy config
|
||||||
const proxyMode = project.proxyMode as string | undefined;
|
const proxyMode = project.proxyMode as string | undefined;
|
||||||
const proxyModel = project.proxyModel as string | undefined;
|
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;
|
||||||
if (proxyMode || proxyModel || llmProvider || llmModel) {
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('Proxy Config:');
|
lines.push('Plugin Config:');
|
||||||
|
lines.push(` ${pad('Plugin:', 18)}${proxyModel}`);
|
||||||
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
|
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
|
||||||
lines.push(` ${pad('ProxyModel:', 18)}${proxyModel || 'default'}`);
|
|
||||||
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}`);
|
||||||
}
|
|
||||||
|
|
||||||
// Servers section
|
// Servers section
|
||||||
const servers = project.servers as Array<{ server: { name: string } }> | undefined;
|
const servers = project.servers as Array<{ server: { name: string } }> | undefined;
|
||||||
@@ -598,9 +596,30 @@ async function resolveLink(linkTarget: string, client: ApiClient): Promise<strin
|
|||||||
|
|
||||||
function formatProxymodelDetail(model: Record<string, unknown>): string {
|
function formatProxymodelDetail(model: Record<string, unknown>): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
const modelType = (model.type as string | undefined) ?? 'pipeline';
|
||||||
lines.push(`=== ProxyModel: ${model.name} ===`);
|
lines.push(`=== ProxyModel: ${model.name} ===`);
|
||||||
lines.push(`${pad('Name:')}${model.name}`);
|
lines.push(`${pad('Name:')}${model.name}`);
|
||||||
lines.push(`${pad('Source:')}${model.source ?? 'unknown'}`);
|
lines.push(`${pad('Source:')}${model.source ?? 'unknown'}`);
|
||||||
|
lines.push(`${pad('Type:')}${modelType}`);
|
||||||
|
|
||||||
|
if (modelType === 'plugin') {
|
||||||
|
if (model.description) lines.push(`${pad('Description:')}${model.description}`);
|
||||||
|
const extendsArr = model.extends as readonly string[] | undefined;
|
||||||
|
if (extendsArr && extendsArr.length > 0) {
|
||||||
|
lines.push(`${pad('Extends:')}${[...extendsArr].join(', ')}`);
|
||||||
|
}
|
||||||
|
const hooks = model.hooks as string[] | undefined;
|
||||||
|
if (hooks && hooks.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Hooks:');
|
||||||
|
for (const h of hooks) {
|
||||||
|
lines.push(` - ${h}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline type
|
||||||
lines.push(`${pad('Controller:')}${model.controller ?? '-'}`);
|
lines.push(`${pad('Controller:')}${model.controller ?? '-'}`);
|
||||||
lines.push(`${pad('Cacheable:')}${model.cacheable ? 'yes' : 'no'}`);
|
lines.push(`${pad('Cacheable:')}${model.cacheable ? 'yes' : 'no'}`);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface ProjectRow {
|
|||||||
description: string;
|
description: string;
|
||||||
proxyMode: string;
|
proxyMode: string;
|
||||||
proxyModel: string;
|
proxyModel: string;
|
||||||
gated: boolean;
|
gated?: boolean;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
servers?: Array<{ server: { name: string } }>;
|
servers?: Array<{ server: { name: string } }>;
|
||||||
}
|
}
|
||||||
@@ -87,8 +87,7 @@ 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: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
||||||
{ header: 'PROXYMODEL', key: (r) => r.proxyModel || 'default', width: 12 },
|
{ header: 'PLUGIN', key: (r) => r.proxyModel || 'default', width: 18 },
|
||||||
{ header: 'GATED', key: (r) => r.gated ? 'yes' : 'no', width: 6 },
|
|
||||||
{ 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 },
|
||||||
{ header: 'ID', key: 'id' },
|
{ header: 'ID', key: 'id' },
|
||||||
@@ -196,17 +195,27 @@ const serverAttachmentColumns: Column<ServerAttachmentRow>[] = [
|
|||||||
interface ProxymodelRow {
|
interface ProxymodelRow {
|
||||||
name: string;
|
name: string;
|
||||||
source: string;
|
source: string;
|
||||||
controller: string;
|
type?: string;
|
||||||
stages: string[];
|
controller?: string;
|
||||||
cacheable: boolean;
|
stages?: string[];
|
||||||
|
cacheable?: boolean;
|
||||||
|
extends?: readonly string[];
|
||||||
|
hooks?: string[];
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxymodelColumns: Column<ProxymodelRow>[] = [
|
const proxymodelColumns: Column<ProxymodelRow>[] = [
|
||||||
{ header: 'NAME', key: 'name' },
|
{ header: 'NAME', key: 'name' },
|
||||||
|
{ header: 'TYPE', key: (r) => r.type ?? 'pipeline', width: 10 },
|
||||||
{ header: 'SOURCE', key: 'source', width: 10 },
|
{ header: 'SOURCE', key: 'source', width: 10 },
|
||||||
{ header: 'CONTROLLER', key: 'controller', width: 12 },
|
{ header: 'DETAIL', key: (r) => {
|
||||||
{ header: 'STAGES', key: (r) => r.stages.join(', '), width: 40 },
|
if (r.type === 'plugin') {
|
||||||
{ header: 'CACHEABLE', key: (r) => r.cacheable ? 'yes' : 'no', width: 10 },
|
const ext = r.extends?.length ? `extends: ${[...r.extends].join(', ')}` : '';
|
||||||
|
const hooks = r.hooks?.length ? `hooks: ${r.hooks.length}` : '';
|
||||||
|
return [ext, hooks].filter(Boolean).join(' | ') || '-';
|
||||||
|
}
|
||||||
|
return r.stages?.join(', ') ?? '-';
|
||||||
|
}, width: 45 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
|
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
|
||||||
|
|||||||
@@ -75,13 +75,16 @@ export async function fetchProjectLlmConfig(
|
|||||||
const config: ProjectLlmConfig = {};
|
const config: ProjectLlmConfig = {};
|
||||||
if (project.llmProvider) config.llmProvider = project.llmProvider;
|
if (project.llmProvider) config.llmProvider = project.llmProvider;
|
||||||
if (project.llmModel) config.llmModel = project.llmModel;
|
if (project.llmModel) config.llmModel = project.llmModel;
|
||||||
if (project.gated !== undefined) config.gated = project.gated;
|
// proxyModel is the primary field; gated is derived from it.
|
||||||
// proxyModel: use project value, fall back to 'default' when gated
|
// Backward compat: if proxyModel is empty, infer from gated boolean.
|
||||||
if (project.proxyModel) {
|
if (project.proxyModel) {
|
||||||
config.proxyModel = project.proxyModel;
|
config.proxyModel = project.proxyModel;
|
||||||
} else if (project.gated !== false) {
|
} else if (project.gated === false) {
|
||||||
|
config.proxyModel = 'content-pipeline';
|
||||||
|
} else {
|
||||||
config.proxyModel = 'default';
|
config.proxyModel = 'default';
|
||||||
}
|
}
|
||||||
|
config.gated = config.proxyModel === 'default' || config.proxyModel === 'gate';
|
||||||
if (project.serverOverrides) config.serverOverrides = project.serverOverrides;
|
if (project.serverOverrides) config.serverOverrides = project.serverOverrides;
|
||||||
return config;
|
return config;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
router.setAuditCollector(auditCollector);
|
router.setAuditCollector(auditCollector);
|
||||||
|
|
||||||
// Wire the default plugin (gate + content-pipeline)
|
// Wire the default plugin (gate + content-pipeline)
|
||||||
const isGated = mcpdConfig.gated !== false;
|
// Derive gating from proxyModel: 'default' and 'gate' include gate behavior
|
||||||
|
const isGated = mcpdConfig.gated !== false &&
|
||||||
|
(proxyModelName === 'default' || proxyModelName === 'gate');
|
||||||
const pluginConfig: Parameters<typeof createDefaultPlugin>[0] = {
|
const pluginConfig: Parameters<typeof createDefaultPlugin>[0] = {
|
||||||
gated: isGated,
|
gated: isGated,
|
||||||
providerRegistry: effectiveRegistry,
|
providerRegistry: effectiveRegistry,
|
||||||
|
|||||||
@@ -1,31 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* ProxyModel discovery endpoints.
|
* ProxyModel discovery endpoints.
|
||||||
*
|
*
|
||||||
* GET /proxymodels → list all available proxymodels
|
* GET /proxymodels → list all available proxymodels (YAML pipelines + TypeScript plugins)
|
||||||
* GET /proxymodels/:name → get a single proxymodel by name
|
* GET /proxymodels/:name → get a single proxymodel by name
|
||||||
*/
|
*/
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { loadProxyModels } from '../proxymodel/loader.js';
|
import { loadProxyModels } from '../proxymodel/loader.js';
|
||||||
|
import { loadPlugins } from '../proxymodel/plugin-loader.js';
|
||||||
|
import { createGatePlugin } from '../proxymodel/plugins/gate.js';
|
||||||
|
import { createContentPipelinePlugin } from '../proxymodel/plugins/content-pipeline.js';
|
||||||
|
import { createDefaultPlugin } from '../proxymodel/plugins/default.js';
|
||||||
|
|
||||||
interface ProxyModelSummary {
|
interface ProxyModelSummary {
|
||||||
name: string;
|
name: string;
|
||||||
source: 'built-in' | 'local';
|
source: 'built-in' | 'local';
|
||||||
controller: string;
|
type: 'pipeline' | 'plugin';
|
||||||
stages: string[];
|
controller?: string;
|
||||||
appliesTo: string[];
|
stages?: string[];
|
||||||
cacheable: boolean;
|
appliesTo?: string[];
|
||||||
|
cacheable?: boolean;
|
||||||
|
extends?: readonly string[];
|
||||||
|
hooks?: string[];
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Built-in plugin factories */
|
||||||
|
const BUILT_IN_PLUGINS = [
|
||||||
|
createGatePlugin(),
|
||||||
|
createContentPipelinePlugin(),
|
||||||
|
createDefaultPlugin(),
|
||||||
|
];
|
||||||
|
|
||||||
export function registerProxymodelEndpoint(app: FastifyInstance): void {
|
export function registerProxymodelEndpoint(app: FastifyInstance): void {
|
||||||
// GET /proxymodels — list all
|
// GET /proxymodels — list all
|
||||||
app.get('/proxymodels', async (_request, reply) => {
|
app.get('/proxymodels', async (_request, reply) => {
|
||||||
const models = await loadProxyModels();
|
|
||||||
const result: ProxyModelSummary[] = [];
|
const result: ProxyModelSummary[] = [];
|
||||||
|
|
||||||
|
// Load YAML pipeline models
|
||||||
|
const models = await loadProxyModels();
|
||||||
for (const model of models.values()) {
|
for (const model of models.values()) {
|
||||||
result.push({
|
result.push({
|
||||||
name: model.metadata.name,
|
name: model.metadata.name,
|
||||||
source: model.source,
|
source: model.source,
|
||||||
|
type: 'pipeline',
|
||||||
controller: model.spec.controller,
|
controller: model.spec.controller,
|
||||||
stages: model.spec.stages.map((s) => s.type),
|
stages: model.spec.stages.map((s) => s.type),
|
||||||
appliesTo: model.spec.appliesTo,
|
appliesTo: model.spec.appliesTo,
|
||||||
@@ -33,28 +50,69 @@ export function registerProxymodelEndpoint(app: FastifyInstance): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load TypeScript plugins (built-in + user)
|
||||||
|
const pipelineNames = new Set(result.map((r) => r.name));
|
||||||
|
const registry = await loadPlugins(BUILT_IN_PLUGINS);
|
||||||
|
for (const entry of registry.list()) {
|
||||||
|
if (pipelineNames.has(entry.name)) continue; // Already listed as pipeline
|
||||||
|
result.push({
|
||||||
|
name: entry.name,
|
||||||
|
source: entry.source,
|
||||||
|
type: 'plugin',
|
||||||
|
extends: entry.plugin.extends,
|
||||||
|
hooks: getPluginHooks(entry.plugin),
|
||||||
|
description: entry.plugin.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
reply.code(200).send(result);
|
reply.code(200).send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /proxymodels/:name — single model details
|
// GET /proxymodels/:name — single model details
|
||||||
app.get<{ Params: { name: string } }>('/proxymodels/:name', async (request, reply) => {
|
app.get<{ Params: { name: string } }>('/proxymodels/:name', async (request, reply) => {
|
||||||
const { name } = request.params;
|
const { name } = request.params;
|
||||||
|
|
||||||
|
// Check YAML pipelines first
|
||||||
const models = await loadProxyModels();
|
const models = await loadProxyModels();
|
||||||
const model = models.get(name);
|
const model = models.get(name);
|
||||||
|
if (model) {
|
||||||
if (!model) {
|
|
||||||
reply.code(404).send({ error: `ProxyModel '${name}' not found` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reply.code(200).send({
|
reply.code(200).send({
|
||||||
name: model.metadata.name,
|
name: model.metadata.name,
|
||||||
source: model.source,
|
source: model.source,
|
||||||
|
type: 'pipeline',
|
||||||
controller: model.spec.controller,
|
controller: model.spec.controller,
|
||||||
controllerConfig: model.spec.controllerConfig,
|
controllerConfig: model.spec.controllerConfig,
|
||||||
stages: model.spec.stages,
|
stages: model.spec.stages,
|
||||||
appliesTo: model.spec.appliesTo,
|
appliesTo: model.spec.appliesTo,
|
||||||
cacheable: model.spec.cacheable,
|
cacheable: model.spec.cacheable,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TypeScript plugins
|
||||||
|
const registry = await loadPlugins(BUILT_IN_PLUGINS);
|
||||||
|
const entry = registry.get(name);
|
||||||
|
if (entry) {
|
||||||
|
reply.code(200).send({
|
||||||
|
name: entry.name,
|
||||||
|
source: entry.source,
|
||||||
|
type: 'plugin',
|
||||||
|
extends: entry.plugin.extends ?? [],
|
||||||
|
hooks: getPluginHooks(entry.plugin),
|
||||||
|
description: entry.plugin.description ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(404).send({ error: `ProxyModel '${name}' not found` });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPluginHooks(plugin: { [key: string]: unknown }): string[] {
|
||||||
|
const hookNames = [
|
||||||
|
'onSessionCreate', 'onSessionDestroy', 'onInitialize',
|
||||||
|
'onToolsList', 'onToolCallBefore', 'onToolCallAfter',
|
||||||
|
'onResourcesList', 'onResourceRead', 'onPromptsList', 'onPromptGet',
|
||||||
|
];
|
||||||
|
return hookNames.filter((h) => typeof plugin[h] === 'function');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Fastify from 'fastify';
|
|||||||
import { registerProxymodelEndpoint } from '../src/http/proxymodel-endpoint.js';
|
import { registerProxymodelEndpoint } from '../src/http/proxymodel-endpoint.js';
|
||||||
|
|
||||||
describe('ProxyModel endpoint', () => {
|
describe('ProxyModel endpoint', () => {
|
||||||
it('GET /proxymodels returns built-in models', async () => {
|
it('GET /proxymodels returns built-in models and plugins', async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
registerProxymodelEndpoint(app);
|
registerProxymodelEndpoint(app);
|
||||||
await app.ready();
|
await app.ready();
|
||||||
@@ -11,26 +11,35 @@ describe('ProxyModel endpoint', () => {
|
|||||||
const res = await app.inject({ method: 'GET', url: '/proxymodels' });
|
const res = await app.inject({ method: 'GET', url: '/proxymodels' });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
const body = res.json<Array<{ name: string; source: string }>>();
|
const body = res.json<Array<{ name: string; source: string; type: string }>>();
|
||||||
expect(Array.isArray(body)).toBe(true);
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
|
||||||
const names = body.map((m) => m.name);
|
const names = body.map((m) => m.name);
|
||||||
|
// YAML pipelines
|
||||||
expect(names).toContain('default');
|
expect(names).toContain('default');
|
||||||
expect(names).toContain('subindex');
|
expect(names).toContain('subindex');
|
||||||
|
// TypeScript plugins
|
||||||
|
expect(names).toContain('gate');
|
||||||
|
expect(names).toContain('content-pipeline');
|
||||||
|
|
||||||
// Each entry has required fields
|
// Pipeline entries have pipeline-specific fields
|
||||||
for (const model of body) {
|
const pipelines = body.filter((m) => m.type === 'pipeline');
|
||||||
expect(model).toHaveProperty('name');
|
for (const model of pipelines) {
|
||||||
expect(model).toHaveProperty('source');
|
|
||||||
expect(model).toHaveProperty('controller');
|
expect(model).toHaveProperty('controller');
|
||||||
expect(model).toHaveProperty('stages');
|
expect(model).toHaveProperty('stages');
|
||||||
expect(model).toHaveProperty('cacheable');
|
expect(model).toHaveProperty('cacheable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin entries have plugin-specific fields
|
||||||
|
const plugins = body.filter((m) => m.type === 'plugin');
|
||||||
|
for (const model of plugins) {
|
||||||
|
expect(model).toHaveProperty('hooks');
|
||||||
|
}
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /proxymodels/:name returns a specific model', async () => {
|
it('GET /proxymodels/:name returns a pipeline model', async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
registerProxymodelEndpoint(app);
|
registerProxymodelEndpoint(app);
|
||||||
await app.ready();
|
await app.ready();
|
||||||
@@ -38,9 +47,10 @@ describe('ProxyModel endpoint', () => {
|
|||||||
const res = await app.inject({ method: 'GET', url: '/proxymodels/default' });
|
const res = await app.inject({ method: 'GET', url: '/proxymodels/default' });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
const body = res.json<{ name: string; source: string; controller: string; stages: unknown[] }>();
|
const body = res.json<{ name: string; source: string; type: string; controller: string; stages: unknown[] }>();
|
||||||
expect(body.name).toBe('default');
|
expect(body.name).toBe('default');
|
||||||
expect(body.source).toBe('built-in');
|
expect(body.source).toBe('built-in');
|
||||||
|
expect(body.type).toBe('pipeline');
|
||||||
expect(body.controller).toBe('gate');
|
expect(body.controller).toBe('gate');
|
||||||
expect(Array.isArray(body.stages)).toBe(true);
|
expect(Array.isArray(body.stages)).toBe(true);
|
||||||
expect(body.stages.length).toBeGreaterThan(0);
|
expect(body.stages.length).toBeGreaterThan(0);
|
||||||
@@ -48,6 +58,24 @@ describe('ProxyModel endpoint', () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /proxymodels/:name returns a plugin', async () => {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
registerProxymodelEndpoint(app);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/proxymodels/gate' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const body = res.json<{ name: string; source: string; type: string; hooks: string[] }>();
|
||||||
|
expect(body.name).toBe('gate');
|
||||||
|
expect(body.source).toBe('built-in');
|
||||||
|
expect(body.type).toBe('plugin');
|
||||||
|
expect(Array.isArray(body.hooks)).toBe(true);
|
||||||
|
expect(body.hooks).toContain('onToolsList');
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /proxymodels/:name returns 404 for unknown model', async () => {
|
it('GET /proxymodels/:name returns 404 for unknown model', async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
registerProxymodelEndpoint(app);
|
registerProxymodelEndpoint(app);
|
||||||
|
|||||||
@@ -30,28 +30,51 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
describe('ProxyModel smoke tests', () => {
|
describe('ProxyModel smoke tests', () => {
|
||||||
describe('mcplocal /proxymodels endpoint', () => {
|
describe('mcplocal /proxymodels endpoint', () => {
|
||||||
it('GET /proxymodels returns built-in models', async () => {
|
it('GET /proxymodels returns pipelines and plugins', async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
const body = await fetchJson<Array<{ name: string; source: string }>>('/proxymodels');
|
const body = await fetchJson<Array<{ name: string; source: string; type: string }>>('/proxymodels');
|
||||||
expect(body).not.toBeNull();
|
expect(body).not.toBeNull();
|
||||||
expect(Array.isArray(body)).toBe(true);
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
|
||||||
const names = body!.map((m) => m.name);
|
const names = body!.map((m) => m.name);
|
||||||
|
// YAML pipelines
|
||||||
expect(names).toContain('default');
|
expect(names).toContain('default');
|
||||||
expect(names).toContain('subindex');
|
expect(names).toContain('subindex');
|
||||||
|
// TypeScript plugins
|
||||||
|
expect(names).toContain('gate');
|
||||||
|
expect(names).toContain('content-pipeline');
|
||||||
|
|
||||||
|
// Check types are correct
|
||||||
|
const defaultModel = body!.find((m) => m.name === 'default');
|
||||||
|
expect(defaultModel!.type).toBe('pipeline');
|
||||||
|
const gatePlugin = body!.find((m) => m.name === 'gate');
|
||||||
|
expect(gatePlugin!.type).toBe('plugin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /proxymodels/default returns model details', async () => {
|
it('GET /proxymodels/default returns pipeline details', async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
const body = await fetchJson<{ name: string; source: string; controller: string; stages: unknown[] }>('/proxymodels/default');
|
const body = await fetchJson<{ name: string; source: string; type: string; controller: string; stages: unknown[] }>('/proxymodels/default');
|
||||||
expect(body).not.toBeNull();
|
expect(body).not.toBeNull();
|
||||||
expect(body!.name).toBe('default');
|
expect(body!.name).toBe('default');
|
||||||
expect(body!.source).toBe('built-in');
|
expect(body!.source).toBe('built-in');
|
||||||
|
expect(body!.type).toBe('pipeline');
|
||||||
expect(Array.isArray(body!.stages)).toBe(true);
|
expect(Array.isArray(body!.stages)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /proxymodels/gate returns plugin details', async () => {
|
||||||
|
if (!available) return;
|
||||||
|
|
||||||
|
const body = await fetchJson<{ name: string; source: string; type: string; hooks: string[] }>('/proxymodels/gate');
|
||||||
|
expect(body).not.toBeNull();
|
||||||
|
expect(body!.name).toBe('gate');
|
||||||
|
expect(body!.type).toBe('plugin');
|
||||||
|
expect(Array.isArray(body!.hooks)).toBe(true);
|
||||||
|
expect(body!.hooks).toContain('onToolsList');
|
||||||
|
expect(body!.hooks).toContain('onInitialize');
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /proxymodels/nonexistent returns 404', async () => {
|
it('GET /proxymodels/nonexistent returns 404', async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
@@ -68,21 +91,56 @@ describe('ProxyModel smoke tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mcpctl CLI', () => {
|
describe('mcpctl CLI', () => {
|
||||||
it('mcpctl get proxymodels returns table with default and subindex', async () => {
|
it('mcpctl get proxymodels shows pipelines and plugins', async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
const output = await mcpctl('get proxymodels');
|
const output = await mcpctl('get proxymodels');
|
||||||
expect(output).toContain('default');
|
expect(output).toContain('default');
|
||||||
expect(output).toContain('subindex');
|
expect(output).toContain('subindex');
|
||||||
|
expect(output).toContain('gate');
|
||||||
|
expect(output).toContain('content-pipeline');
|
||||||
expect(output).toContain('NAME');
|
expect(output).toContain('NAME');
|
||||||
|
expect(output).toContain('TYPE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mcpctl describe proxymodel default shows details', async () => {
|
it('mcpctl describe proxymodel default shows pipeline type', async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
const output = await mcpctl('describe proxymodel default');
|
const output = await mcpctl('describe proxymodel default');
|
||||||
expect(output).toContain('default');
|
expect(output).toContain('default');
|
||||||
expect(output).toContain('built-in');
|
expect(output).toContain('built-in');
|
||||||
|
expect(output).toContain('pipeline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mcpctl describe proxymodel gate shows plugin type with hooks', async () => {
|
||||||
|
if (!available) return;
|
||||||
|
|
||||||
|
const output = await mcpctl('describe proxymodel gate');
|
||||||
|
expect(output).toContain('gate');
|
||||||
|
expect(output).toContain('plugin');
|
||||||
|
expect(output).toContain('Hooks');
|
||||||
|
expect(output).toContain('onToolsList');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mcpctl get projects shows PLUGIN column instead of GATED', async () => {
|
||||||
|
if (!available) return;
|
||||||
|
|
||||||
|
const output = await mcpctl('get projects');
|
||||||
|
expect(output).toContain('PLUGIN');
|
||||||
|
expect(output).not.toContain('GATED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mcpctl describe project shows Plugin Config section', async () => {
|
||||||
|
if (!available) return;
|
||||||
|
|
||||||
|
// Get first project name
|
||||||
|
const json = await mcpctl('get projects -o json');
|
||||||
|
const projects = JSON.parse(json) as Array<{ name: string }>;
|
||||||
|
if (projects.length === 0) return;
|
||||||
|
|
||||||
|
const output = await mcpctl(`describe project ${projects[0]!.name}`);
|
||||||
|
expect(output).toContain('Plugin Config');
|
||||||
|
expect(output).toContain('Plugin:');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user