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:
@@ -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);
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user