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:
Michal
2026-03-07 00:32:13 +00:00
parent 86c5a61eaa
commit a22a17f8d3
10 changed files with 247 additions and 64 deletions

View File

@@ -75,13 +75,16 @@ export async function fetchProjectLlmConfig(
const config: ProjectLlmConfig = {};
if (project.llmProvider) config.llmProvider = project.llmProvider;
if (project.llmModel) config.llmModel = project.llmModel;
if (project.gated !== undefined) config.gated = project.gated;
// proxyModel: use project value, fall back to 'default' when gated
// proxyModel is the primary field; gated is derived from it.
// Backward compat: if proxyModel is empty, infer from gated boolean.
if (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.gated = config.proxyModel === 'default' || config.proxyModel === 'gate';
if (project.serverOverrides) config.serverOverrides = project.serverOverrides;
return config;
} catch {

View File

@@ -109,7 +109,9 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
router.setAuditCollector(auditCollector);
// 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] = {
gated: isGated,
providerRegistry: effectiveRegistry,

View File

@@ -1,31 +1,48 @@
/**
* 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
*/
import type { FastifyInstance } from 'fastify';
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 {
name: string;
source: 'built-in' | 'local';
controller: string;
stages: string[];
appliesTo: string[];
cacheable: boolean;
type: 'pipeline' | 'plugin';
controller?: string;
stages?: string[];
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 {
// GET /proxymodels — list all
app.get('/proxymodels', async (_request, reply) => {
const models = await loadProxyModels();
const result: ProxyModelSummary[] = [];
// Load YAML pipeline models
const models = await loadProxyModels();
for (const model of models.values()) {
result.push({
name: model.metadata.name,
source: model.source,
type: 'pipeline',
controller: model.spec.controller,
stages: model.spec.stages.map((s) => s.type),
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);
});
// GET /proxymodels/:name — single model details
app.get<{ Params: { name: string } }>('/proxymodels/:name', async (request, reply) => {
const { name } = request.params;
// Check YAML pipelines first
const models = await loadProxyModels();
const model = models.get(name);
if (!model) {
reply.code(404).send({ error: `ProxyModel '${name}' not found` });
if (model) {
reply.code(200).send({
name: model.metadata.name,
source: model.source,
type: 'pipeline',
controller: model.spec.controller,
controllerConfig: model.spec.controllerConfig,
stages: model.spec.stages,
appliesTo: model.spec.appliesTo,
cacheable: model.spec.cacheable,
});
return;
}
reply.code(200).send({
name: model.metadata.name,
source: model.source,
controller: model.spec.controller,
controllerConfig: model.spec.controllerConfig,
stages: model.spec.stages,
appliesTo: model.spec.appliesTo,
cacheable: model.spec.cacheable,
});
// 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');
}

View File

@@ -3,7 +3,7 @@ import Fastify from 'fastify';
import { registerProxymodelEndpoint } from '../src/http/proxymodel-endpoint.js';
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 });
registerProxymodelEndpoint(app);
await app.ready();
@@ -11,26 +11,35 @@ describe('ProxyModel endpoint', () => {
const res = await app.inject({ method: 'GET', url: '/proxymodels' });
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);
const names = body.map((m) => m.name);
// YAML pipelines
expect(names).toContain('default');
expect(names).toContain('subindex');
// TypeScript plugins
expect(names).toContain('gate');
expect(names).toContain('content-pipeline');
// Each entry has required fields
for (const model of body) {
expect(model).toHaveProperty('name');
expect(model).toHaveProperty('source');
// Pipeline entries have pipeline-specific fields
const pipelines = body.filter((m) => m.type === 'pipeline');
for (const model of pipelines) {
expect(model).toHaveProperty('controller');
expect(model).toHaveProperty('stages');
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();
});
it('GET /proxymodels/:name returns a specific model', async () => {
it('GET /proxymodels/:name returns a pipeline model', async () => {
const app = Fastify({ logger: false });
registerProxymodelEndpoint(app);
await app.ready();
@@ -38,9 +47,10 @@ describe('ProxyModel endpoint', () => {
const res = await app.inject({ method: 'GET', url: '/proxymodels/default' });
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.source).toBe('built-in');
expect(body.type).toBe('pipeline');
expect(body.controller).toBe('gate');
expect(Array.isArray(body.stages)).toBe(true);
expect(body.stages.length).toBeGreaterThan(0);
@@ -48,6 +58,24 @@ describe('ProxyModel endpoint', () => {
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 () => {
const app = Fastify({ logger: false });
registerProxymodelEndpoint(app);

View File

@@ -30,28 +30,51 @@ beforeAll(async () => {
describe('ProxyModel smoke tests', () => {
describe('mcplocal /proxymodels endpoint', () => {
it('GET /proxymodels returns built-in models', async () => {
it('GET /proxymodels returns pipelines and plugins', async () => {
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(Array.isArray(body)).toBe(true);
const names = body!.map((m) => m.name);
// YAML pipelines
expect(names).toContain('default');
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;
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!.name).toBe('default');
expect(body!.source).toBe('built-in');
expect(body!.type).toBe('pipeline');
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 () => {
if (!available) return;
@@ -68,21 +91,56 @@ describe('ProxyModel smoke tests', () => {
});
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;
const output = await mcpctl('get proxymodels');
expect(output).toContain('default');
expect(output).toContain('subindex');
expect(output).toContain('gate');
expect(output).toContain('content-pipeline');
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;
const output = await mcpctl('describe proxymodel default');
expect(output).toContain('default');
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:');
});
});
});