From a22a17f8d38b7f64b2fc3bcabb2c0e28d2bc0a3f Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 7 Mar 2026 00:32:13 +0000 Subject: [PATCH] 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 --- completions/mcpctl.fish | 6 +- src/cli/src/commands/apply.ts | 2 +- src/cli/src/commands/create.ts | 14 ++- src/cli/src/commands/describe.ts | 41 +++++--- src/cli/src/commands/get.ts | 27 ++++-- src/mcplocal/src/discovery.ts | 9 +- src/mcplocal/src/http/project-mcp-endpoint.ts | 4 +- src/mcplocal/src/http/proxymodel-endpoint.ts | 94 +++++++++++++++---- .../tests/proxymodel-endpoint.test.ts | 44 +++++++-- src/mcplocal/tests/smoke/proxymodel.test.ts | 70 ++++++++++++-- 10 files changed, 247 insertions(+), 64 deletions(-) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 136ed6c..0a6334f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -314,10 +314,10 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l force -d 'Update # 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" -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 gated -d 'Enable gated sessions (default: true)' -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 gated -d '[deprecated: use --proxy-model default]' +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 force -d 'Update if already exists' diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 5b858c8..137fe92 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -127,7 +127,7 @@ const ProjectSpecSchema = z.object({ prompt: z.string().max(10000).default(''), proxyMode: z.enum(['direct', 'filtered']).default('direct'), proxyModel: z.string().optional(), - gated: z.boolean().default(true), + gated: z.boolean().optional(), llmProvider: z.string().optional(), llmModel: z.string().optional(), servers: z.array(z.string()).default([]), diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 04ae514..2327c86 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -226,10 +226,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .argument('', 'Project name') .option('-d, --description ', 'Project description', '') .option('--proxy-mode ', 'Proxy mode (direct, filtered)') - .option('--proxy-model ', 'ProxyModel pipeline name (e.g. default, subindex)') + .option('--proxy-model ', 'Plugin name (default, content-pipeline, gate, none)') .option('--prompt ', 'Project-level prompt / instructions for the LLM') - .option('--gated', 'Enable gated sessions (default: true)') - .option('--no-gated', 'Disable gated sessions') + .option('--gated', '[deprecated: use --proxy-model default]') + .option('--no-gated', '[deprecated: use --proxy-model content-pipeline]') .option('--server ', 'Server name (repeat for multiple)', collect, []) .option('--force', 'Update if already exists') .action(async (name: string, opts) => { @@ -239,7 +239,13 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { proxyMode: opts.proxyMode ?? 'direct', }; 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.server.length > 0) body.servers = opts.server; diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 9457080..887dbeb 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -142,21 +142,19 @@ function formatProjectDetail( lines.push(`=== Project: ${project.name} ===`); lines.push(`${pad('Name:')}${project.name}`); 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 proxyModel = project.proxyModel as string | undefined; + const proxyModel = (project.proxyModel as string | undefined) || 'default'; const llmProvider = project.llmProvider as string | undefined; const llmModel = project.llmModel as string | undefined; - if (proxyMode || proxyModel || llmProvider || llmModel) { - lines.push(''); - lines.push('Proxy Config:'); - lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`); - lines.push(` ${pad('ProxyModel:', 18)}${proxyModel || 'default'}`); - if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`); - if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`); - } + + lines.push(''); + lines.push('Plugin Config:'); + lines.push(` ${pad('Plugin:', 18)}${proxyModel}`); + lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`); + if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`); + if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`); // Servers section const servers = project.servers as Array<{ server: { name: string } }> | undefined; @@ -598,9 +596,30 @@ async function resolveLink(linkTarget: string, client: ApiClient): Promise): string { const lines: string[] = []; + const modelType = (model.type as string | undefined) ?? 'pipeline'; lines.push(`=== ProxyModel: ${model.name} ===`); lines.push(`${pad('Name:')}${model.name}`); 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('Cacheable:')}${model.cacheable ? 'yes' : 'no'}`); diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 2ebf8e1..f22609c 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -25,7 +25,7 @@ interface ProjectRow { description: string; proxyMode: string; proxyModel: string; - gated: boolean; + gated?: boolean; ownerId: string; servers?: Array<{ server: { name: string } }>; } @@ -87,8 +87,7 @@ interface RbacRow { const projectColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 }, - { header: 'PROXYMODEL', key: (r) => r.proxyModel || 'default', width: 12 }, - { header: 'GATED', key: (r) => r.gated ? 'yes' : 'no', width: 6 }, + { header: 'PLUGIN', key: (r) => r.proxyModel || 'default', width: 18 }, { header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 }, { header: 'DESCRIPTION', key: 'description', width: 30 }, { header: 'ID', key: 'id' }, @@ -196,17 +195,27 @@ const serverAttachmentColumns: Column[] = [ interface ProxymodelRow { name: string; source: string; - controller: string; - stages: string[]; - cacheable: boolean; + type?: string; + controller?: string; + stages?: string[]; + cacheable?: boolean; + extends?: readonly string[]; + hooks?: string[]; + description?: string; } const proxymodelColumns: Column[] = [ { header: 'NAME', key: 'name' }, + { header: 'TYPE', key: (r) => r.type ?? 'pipeline', width: 10 }, { header: 'SOURCE', key: 'source', width: 10 }, - { header: 'CONTROLLER', key: 'controller', width: 12 }, - { header: 'STAGES', key: (r) => r.stages.join(', '), width: 40 }, - { header: 'CACHEABLE', key: (r) => r.cacheable ? 'yes' : 'no', width: 10 }, + { header: 'DETAIL', key: (r) => { + if (r.type === 'plugin') { + 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>[] { diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index 8bf52f9..7043f25 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -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 { diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 9628f34..79b18e1 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -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[0] = { gated: isGated, providerRegistry: effectiveRegistry, diff --git a/src/mcplocal/src/http/proxymodel-endpoint.ts b/src/mcplocal/src/http/proxymodel-endpoint.ts index 7900b02..dbfe9c6 100644 --- a/src/mcplocal/src/http/proxymodel-endpoint.ts +++ b/src/mcplocal/src/http/proxymodel-endpoint.ts @@ -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'); +} diff --git a/src/mcplocal/tests/proxymodel-endpoint.test.ts b/src/mcplocal/tests/proxymodel-endpoint.test.ts index 661d535..fffdb47 100644 --- a/src/mcplocal/tests/proxymodel-endpoint.test.ts +++ b/src/mcplocal/tests/proxymodel-endpoint.test.ts @@ -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>(); + const body = res.json>(); 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); diff --git a/src/mcplocal/tests/smoke/proxymodel.test.ts b/src/mcplocal/tests/smoke/proxymodel.test.ts index 8299603..b46e3c4 100644 --- a/src/mcplocal/tests/smoke/proxymodel.test.ts +++ b/src/mcplocal/tests/smoke/proxymodel.test.ts @@ -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>('/proxymodels'); + const body = await fetchJson>('/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:'); }); }); });