feat(proxy): favourite-index tool presentation (favourite/ + all/ + prefer instruction)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m36s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m16s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m36s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m16s
CI/CD / publish (pull_request) Has been skipped
Measured winner from the DGX-Spark bake-off (toolsim.py, 145-tool catalog): a
curated favourite/<tool> shortlist + the full all/<server>/<tool> catalog + a
load-bearing "prefer favourite/ first" instruction nearly halved wander (37→20)
and 2.5x'd first-pick (2→5/8) vs a flat catalog. The instruction is load-bearing;
enriching descriptions did not help.
- New mcplocal plugin `favourite-index.ts`: composes AFTER gate (no-ops while
gated), reshapes the ungated upstream catalog into favourite/ + all/, injects
the instruction (onInitialize), and rewrites presented names back to canonical
server/tool in onToolCallBefore so normal routing + content-pipeline still run.
Gate/agent virtual tools pass through untouched; favourites are upstream-only.
- compose.ts: onInitialize now concatenates plugin instructions (was first-non-null)
so favindex can contribute its banner alongside the gate's.
- Per-project config `Project.favouriteIndex` {enabled, tools[], maxFavourites};
surfaced to the proxy via discovery; wired at project-mcp-endpoint when enabled.
- Usage derivation: mcpd tool-usage ranking over tool_call_trace events
(normalizing presented names → canonical), GET /api/v1/audit/tool-usage, and
`mcpctl favourites suggest|list`.
- CLI: `create project` gains --favourite/--favourite-index/--max-favourites;
favouriteIndex round-trips through get -o yaml | apply -f. Completions regenerated.
- Tests: plugin unit (presentation, rewrite routing, gated no-op, collisions),
compose merge, canonicalizeToolName, buildFavouriteIndex, + a live smoke test.
- Docs: docs/tool-presentation.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -197,6 +197,13 @@ const ProjectSpecSchema = z.object({
|
||||
// model when unset.
|
||||
llmModel: z.string().optional(),
|
||||
servers: z.array(z.string()).default([]),
|
||||
// Two-namespace tool presentation: curated favourite/<tool> + full
|
||||
// all/<server>/<tool>. `tools` are canonical `server/tool` pins.
|
||||
favouriteIndex: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
maxFavourites: z.number().int().positive().optional(),
|
||||
}).nullable().optional(),
|
||||
});
|
||||
|
||||
const McpTokenSpecSchema = z.object({
|
||||
|
||||
@@ -11,6 +11,37 @@ function collect(value: string, prev: string[]): string[] {
|
||||
return [...prev, value];
|
||||
}
|
||||
|
||||
export interface FavouriteIndexOpts {
|
||||
favourite?: string[];
|
||||
favouriteIndex?: boolean;
|
||||
maxFavourites?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a project `favouriteIndex` config from CLI flags, or undefined when no
|
||||
* favourite-index flag was given (so the field is only sent when intended).
|
||||
*/
|
||||
export function buildFavouriteIndex(
|
||||
opts: FavouriteIndexOpts,
|
||||
): { enabled: boolean; tools?: string[]; maxFavourites?: number } | undefined {
|
||||
const tools = opts.favourite ?? [];
|
||||
const hasFlag = opts.favouriteIndex !== undefined || tools.length > 0 || opts.maxFavourites !== undefined;
|
||||
if (!hasFlag) return undefined;
|
||||
// --no-favourite-index → false; any other favourite flag implies enabled.
|
||||
const result: { enabled: boolean; tools?: string[]; maxFavourites?: number } = {
|
||||
enabled: opts.favouriteIndex !== false,
|
||||
};
|
||||
if (tools.length > 0) result.tools = tools;
|
||||
if (opts.maxFavourites !== undefined) {
|
||||
const n = Number(opts.maxFavourites);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`Invalid --max-favourites '${opts.maxFavourites}'. Expected a positive integer.`);
|
||||
}
|
||||
result.maxFavourites = n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `--ttl` value.
|
||||
*
|
||||
@@ -521,6 +552,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--gated', '[deprecated: use --proxy-model default]')
|
||||
.option('--no-gated', '[deprecated: use --proxy-model content-pipeline]')
|
||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||
.option('--favourite <server/tool>', 'Pin a tool to the favourite/ shortlist (repeat; enables favourite-index)', collect, [])
|
||||
.option('--favourite-index', 'Enable the favourite/ + all/ tool presentation')
|
||||
.option('--no-favourite-index', 'Disable the favourite-index presentation')
|
||||
.option('--max-favourites <n>', 'Cap how many favourites are presented')
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -539,6 +574,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
if (opts.server.length > 0) body.servers = opts.server;
|
||||
if (opts.llm) body.llmProvider = opts.llm;
|
||||
if (opts.llmModel) body.llmModel = opts.llmModel;
|
||||
const favIndex = buildFavouriteIndex(opts as FavouriteIndexOpts);
|
||||
if (favIndex) body.favouriteIndex = favIndex;
|
||||
|
||||
try {
|
||||
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
|
||||
|
||||
105
src/cli/src/commands/favourites.ts
Normal file
105
src/cli/src/commands/favourites.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveNameOrId } from './shared.js';
|
||||
|
||||
export interface FavouritesCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: string[]) => void;
|
||||
getProject: () => string | undefined;
|
||||
}
|
||||
|
||||
interface ToolUsageEntry {
|
||||
tool: string;
|
||||
server: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ProjectFavouriteIndex {
|
||||
favouriteIndex?: { enabled?: boolean; tools?: string[]; maxFavourites?: number };
|
||||
}
|
||||
|
||||
function requireProject(deps: FavouritesCommandDeps): string {
|
||||
const project = deps.getProject();
|
||||
if (!project) {
|
||||
deps.log('Error: --project <name> is required for this command.');
|
||||
process.exitCode = 1;
|
||||
throw new Error('--project required');
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* `mcpctl favourites` — inspect + derive the curated favourite/ shortlist used
|
||||
* by the favourite-index tool presentation. `suggest` ranks tools by real usage
|
||||
* so you can pick pins; `list` shows what the proxy will present.
|
||||
*/
|
||||
export function createFavouritesCommand(deps: FavouritesCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('favourites')
|
||||
.description('Inspect / derive a project\'s favourite-index tool shortlist (requires --project)');
|
||||
|
||||
cmd.command('suggest')
|
||||
.description('Rank tools by usage to pick favourite/ pins')
|
||||
.option('--top <n>', 'How many to show (default 20)', '20')
|
||||
.option('--window <days>', 'Look-back window in days (default 30)', '30')
|
||||
.action(async (opts: { top: string; window: string }) => {
|
||||
const projectName = requireProject(deps);
|
||||
const top = Number(opts.top) > 0 ? Number(opts.top) : 20;
|
||||
const windowDays = Number(opts.window) > 0 ? Number(opts.window) : 30;
|
||||
const res = await client.get<{ tools: ToolUsageEntry[] }>(
|
||||
`/api/v1/audit/tool-usage?projectName=${encodeURIComponent(projectName)}&window=${windowDays}&limit=${top}`,
|
||||
);
|
||||
const tools = res.tools ?? [];
|
||||
|
||||
// Mark which candidates are already pinned.
|
||||
const project = await client.get<ProjectFavouriteIndex>(
|
||||
`/api/v1/projects/${await resolveNameOrId(client, 'projects', projectName)}`,
|
||||
);
|
||||
const pinned = new Set(project.favouriteIndex?.tools ?? []);
|
||||
|
||||
if (tools.length === 0) {
|
||||
log(`No tool-call usage recorded for project '${projectName}' in the last ${windowDays}d.`);
|
||||
return;
|
||||
}
|
||||
log(`Top ${tools.length} tools for '${projectName}' (last ${windowDays}d):`);
|
||||
log('');
|
||||
log(` ${'#'.padStart(3)} ${'CALLS'.padStart(6)} ${'PIN'.padEnd(3)} TOOL`);
|
||||
log(` ${'-'.repeat(3)} ${'-'.repeat(6)} ${'-'.repeat(3)} ${'-'.repeat(30)}`);
|
||||
tools.forEach((t, i) => {
|
||||
const mark = pinned.has(t.tool) ? '★' : ' ';
|
||||
log(` ${String(i + 1).padStart(3)} ${String(t.count).padStart(6)} ${mark} ${t.tool}`);
|
||||
});
|
||||
const unpinned = tools.filter((t) => !pinned.has(t.tool) && !t.tool.startsWith('favourite/'));
|
||||
if (unpinned.length > 0) {
|
||||
log('');
|
||||
log('Pin the common ones (★ = already pinned):');
|
||||
const flags = unpinned.slice(0, 10).map((t) => `--favourite ${t.tool}`).join(' ');
|
||||
log(` mcpctl create project ${projectName} --force --favourite-index ${flags}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command('list')
|
||||
.description('Show the favourite-index config the proxy will present')
|
||||
.action(async () => {
|
||||
const projectName = requireProject(deps);
|
||||
const project = await client.get<ProjectFavouriteIndex>(
|
||||
`/api/v1/projects/${await resolveNameOrId(client, 'projects', projectName)}`,
|
||||
);
|
||||
const fav = project.favouriteIndex;
|
||||
if (!fav || !fav.enabled) {
|
||||
log(`favourite-index is DISABLED for project '${projectName}'.`);
|
||||
log(`Enable it with: mcpctl create project ${projectName} --force --favourite-index --favourite <server/tool> ...`);
|
||||
return;
|
||||
}
|
||||
const tools = fav.tools ?? [];
|
||||
log(`favourite-index ENABLED for '${projectName}'${fav.maxFavourites ? ` (cap ${fav.maxFavourites})` : ''}:`);
|
||||
if (tools.length === 0) {
|
||||
log(' (no pins — nothing will be promoted to favourite/)');
|
||||
} else {
|
||||
tools.forEach((t, i) => log(` ${String(i + 1).padStart(3)} ${t}`));
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { createEditCommand } from './commands/edit.js';
|
||||
import { createBackupCommand } from './commands/backup.js';
|
||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
|
||||
import { createFavouritesCommand } from './commands/favourites.js';
|
||||
import { createMcpCommand } from './commands/mcp.js';
|
||||
import { createPatchCommand } from './commands/patch.js';
|
||||
import { createConsoleCommand } from './commands/console/index.js';
|
||||
@@ -290,6 +291,7 @@ export function createProgram(): Command {
|
||||
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createApproveCommand(projectOpsDeps));
|
||||
program.addCommand(createFavouritesCommand(projectOpsDeps));
|
||||
// PR-4: reviewer queue for proposed prompts + skills.
|
||||
program.addCommand(createReviewCommand({
|
||||
client,
|
||||
|
||||
31
src/cli/tests/favourite-index-opts.test.ts
Normal file
31
src/cli/tests/favourite-index-opts.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildFavouriteIndex } from '../src/commands/create.js';
|
||||
|
||||
describe('buildFavouriteIndex — CLI flags → project favouriteIndex', () => {
|
||||
it('returns undefined when no favourite flag is given', () => {
|
||||
expect(buildFavouriteIndex({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enables and sets the pins when --favourite is given', () => {
|
||||
expect(buildFavouriteIndex({ favourite: ['k8s/get_pods', 'vault/read_secret'] })).toEqual({
|
||||
enabled: true,
|
||||
tools: ['k8s/get_pods', 'vault/read_secret'],
|
||||
});
|
||||
});
|
||||
|
||||
it('--no-favourite-index disables', () => {
|
||||
expect(buildFavouriteIndex({ favouriteIndex: false })).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('parses --max-favourites into an integer', () => {
|
||||
expect(buildFavouriteIndex({ favouriteIndex: true, maxFavourites: '15' })).toEqual({
|
||||
enabled: true,
|
||||
maxFavourites: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a non-positive-integer --max-favourites', () => {
|
||||
expect(() => buildFavouriteIndex({ maxFavourites: 'abc' })).toThrow(/max-favourites/);
|
||||
expect(() => buildFavouriteIndex({ maxFavourites: '0' })).toThrow(/max-favourites/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user