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

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:
Michal
2026-07-23 01:20:30 +01:00
parent 574fc63bb1
commit f614e9bb98
25 changed files with 919 additions and 14 deletions

View File

@@ -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({

View File

@@ -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);

View 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;
}

View File

@@ -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,

View 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/);
});
});