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

@@ -1,5 +1,29 @@
import type { PrismaClient, AuditEvent, Prisma } from '@prisma/client';
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput, AuditSessionSummary } from './interfaces.js';
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput, AuditSessionSummary, ToolUsageEntry } from './interfaces.js';
/**
* Normalize a called tool name to its canonical `server/tool` identity so
* usage counts collapse across the presentation namespaces. The favourite-index
* mode logs the *presented* name (`all/<server>/<tool>` or `favourite/<tool>`)
* in the audit trace, so `all/…` is stripped back to canonical; `favourite/…`
* is already-pinned and kept distinct. `serverName` is the trace's resolved
* server (null for presented names).
*/
export function canonicalizeToolName(
toolName: string,
serverName: string | null,
): { key: string; server: string | null } {
if (toolName.startsWith('all/')) {
const rest = toolName.slice('all/'.length);
const i = rest.indexOf('/');
return { key: rest, server: i < 0 ? null : rest.slice(0, i) };
}
if (toolName.startsWith('favourite/')) {
return { key: toolName, server: serverName };
}
const i = toolName.indexOf('/');
return { key: toolName, server: i < 0 ? serverName : toolName.slice(0, i) };
}
export class AuditEventRepository implements IAuditEventRepository {
constructor(private readonly prisma: PrismaClient) {}
@@ -105,6 +129,30 @@ export class AuditEventRepository implements IAuditEventRepository {
}));
}
async toolUsage(projectName: string, from: Date, sampleLimit = 10000): Promise<ToolUsageEntry[]> {
// toolName lives in the payload JSON (un-indexed), so aggregate in-app over
// a bounded recent sample rather than a JSON groupBy.
const rows = await this.prisma.auditEvent.findMany({
where: { projectName, eventKind: 'tool_call_trace', timestamp: { gte: from } },
select: { serverName: true, payload: true },
orderBy: { timestamp: 'desc' },
take: sampleLimit,
});
const counts = new Map<string, { server: string | null; count: number }>();
for (const r of rows) {
const toolName = (r.payload as { toolName?: unknown } | null)?.toolName;
if (typeof toolName !== 'string' || toolName === '') continue;
const { key, server } = canonicalizeToolName(toolName, r.serverName);
const cur = counts.get(key) ?? { server, count: 0 };
cur.count += 1;
if (cur.server === null && server !== null) cur.server = server;
counts.set(key, cur);
}
return [...counts.entries()]
.map(([tool, v]) => ({ tool, server: v.server, count: v.count }))
.sort((a, b) => b.count - a.count);
}
async countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;

View File

@@ -92,6 +92,13 @@ export interface AuditSessionSummary {
eventKinds: string[];
}
/** A tool ranked by how often it was invoked (canonical `server/tool`). */
export interface ToolUsageEntry {
tool: string;
server: string | null;
count: number;
}
export interface IAuditEventRepository {
findAll(filter?: AuditEventFilter): Promise<AuditEvent[]>;
findById(id: string): Promise<AuditEvent | null>;
@@ -99,6 +106,8 @@ export interface IAuditEventRepository {
count(filter?: AuditEventFilter): Promise<number>;
listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
/** Rank tools by invocation count for a project (from tool_call_trace events). */
toolUsage(projectName: string, from: Date, sampleLimit?: number): Promise<ToolUsageEntry[]>;
}
// ── MCP Tokens ──

View File

@@ -12,7 +12,7 @@ export interface IProjectRepository {
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: string): Promise<ProjectWithRelations | null>;
findByName(name: string): Promise<ProjectWithRelations | null>;
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown> }): Promise<ProjectWithRelations>;
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown>; favouriteIndex?: Record<string, unknown> }): Promise<ProjectWithRelations>;
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
delete(id: string): Promise<void>;
setServers(projectId: string, serverIds: string[]): Promise<void>;
@@ -36,7 +36,7 @@ export class ProjectRepository implements IProjectRepository {
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
}
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown> }): Promise<ProjectWithRelations> {
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyModel?: string; gated?: boolean; llmProvider?: string; llmModel?: string; serverOverrides?: Record<string, unknown>; favouriteIndex?: Record<string, unknown> }): Promise<ProjectWithRelations> {
const createData: Record<string, unknown> = {
name: data.name,
description: data.description,
@@ -48,6 +48,7 @@ export class ProjectRepository implements IProjectRepository {
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
if (data.serverOverrides !== undefined) createData['serverOverrides'] = data.serverOverrides;
if (data.favouriteIndex !== undefined) createData['favouriteIndex'] = data.favouriteIndex;
return this.prisma.project.create({
data: createData as Parameters<PrismaClient['project']['create']>[0]['data'],

View File

@@ -58,6 +58,15 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
return service.getById(request.params.id);
});
// GET /api/v1/audit/tool-usage — rank tools by invocation count (for favourites)
app.get<{ Querystring: { projectName?: string; window?: string; limit?: string } }>('/api/v1/audit/tool-usage', async (request, reply) => {
const q = request.query;
if (!q.projectName) return reply.code(400).send({ error: 'projectName is required' });
const windowDays = q.window !== undefined ? parseInt(q.window, 10) : 30;
const limit = q.limit !== undefined ? parseInt(q.limit, 10) : 50;
return { tools: await service.toolUsage(q.projectName, windowDays, limit) };
});
// GET /api/v1/audit/sessions — list sessions with aggregates
app.get<{ Querystring: { projectName?: string; userName?: string; from?: string; to?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => {
const q = request.query;

View File

@@ -63,6 +63,17 @@ export class AuditEventService {
return { sessions, total };
}
/**
* Rank a project's tools by invocation count over a recent window, to derive
* a usage-weighted favourite shortlist. Presented names are normalized to
* canonical `server/tool` in the repository.
*/
async toolUsage(projectName: string, windowDays = 30, limit = 50): Promise<Awaited<ReturnType<IAuditEventRepository['toolUsage']>>> {
const from = new Date(Date.now() - windowDays * 86400_000);
const ranked = await this.repo.toolUsage(projectName, from);
return limit > 0 ? ranked.slice(0, limit) : ranked;
}
private buildFilter(params?: AuditEventQueryParams): AuditEventFilter | undefined {
if (!params) return undefined;
const filter: AuditEventFilter = {};

View File

@@ -70,8 +70,8 @@ function toApplyDoc(kind: string, raw: Record<string, unknown>): Record<string,
continue;
}
// ServerOverrides: keep as-is if not empty
if (key === 'serverOverrides') {
// JSON config bags: keep as-is only if a non-empty object.
if (key === 'serverOverrides' || key === 'favouriteIndex') {
if (value && typeof value === 'object' && Object.keys(value as object).length > 0) {
result[key] = value;
}

View File

@@ -91,6 +91,7 @@ export class ProjectService {
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
...(data.serverOverrides !== undefined ? { serverOverrides: data.serverOverrides } : {}),
...(data.favouriteIndex !== undefined ? { favouriteIndex: data.favouriteIndex } : {}),
});
// Link servers
@@ -115,6 +116,7 @@ export class ProjectService {
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
if (data.gated !== undefined) updateData['gated'] = data.gated;
if (data.serverOverrides !== undefined) updateData['serverOverrides'] = data.serverOverrides;
if (data.favouriteIndex !== undefined) updateData['favouriteIndex'] = data.favouriteIndex;
// Update scalar fields if any changed
if (Object.keys(updateData).length > 0) {
@@ -188,6 +190,7 @@ export class ProjectService {
if (data['llmProvider'] !== undefined) scalarFields['llmProvider'] = data['llmProvider'];
if (data['llmModel'] !== undefined) scalarFields['llmModel'] = data['llmModel'];
if (data['serverOverrides'] !== undefined) scalarFields['serverOverrides'] = data['serverOverrides'];
if (data['favouriteIndex'] !== undefined) scalarFields['favouriteIndex'] = data['favouriteIndex'];
if (existing !== null) {
if (Object.keys(scalarFields).length > 0) {

View File

@@ -1,5 +1,15 @@
import { z } from 'zod';
/**
* Two-namespace `favourite/` + `all/` tool presentation (per-project).
* `tools` are canonical `server/tool` pins surfaced as favourites, in order.
*/
export const FavouriteIndexSchema = z.object({
enabled: z.boolean().optional(),
tools: z.array(z.string().min(1)).optional(),
maxFavourites: z.number().int().positive().max(200).optional(),
});
export const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
@@ -12,6 +22,7 @@ export const CreateProjectSchema = z.object({
serverOverrides: z.record(z.string(), z.object({
proxyModel: z.string().optional(),
})).optional(),
favouriteIndex: FavouriteIndexSchema.optional(),
// Backward compat: accept but ignore proxyMode from old configs
proxyMode: z.string().optional(),
}).transform(({ proxyMode: _ignored, ...rest }) => rest);
@@ -27,6 +38,7 @@ export const UpdateProjectSchema = z.object({
serverOverrides: z.record(z.string(), z.object({
proxyModel: z.string().optional(),
})).optional(),
favouriteIndex: FavouriteIndexSchema.nullable().optional(),
// Backward compat: accept but ignore proxyMode from old configs
proxyMode: z.string().optional(),
}).transform(({ proxyMode: _ignored, ...rest }) => rest);

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { canonicalizeToolName } from '../src/repositories/audit-event.repository.js';
describe('canonicalizeToolName — normalize presented tool names for usage ranking', () => {
it('strips all/ back to canonical server/tool and derives the server', () => {
expect(canonicalizeToolName('all/k8s/get_pods', null)).toEqual({ key: 'k8s/get_pods', server: 'k8s' });
});
it('keeps favourite/ distinct (already a pin), using the trace server if any', () => {
expect(canonicalizeToolName('favourite/get_pods', null)).toEqual({ key: 'favourite/get_pods', server: null });
});
it('passes a canonical server/tool through, deriving the server from the name', () => {
expect(canonicalizeToolName('vault/read_secret', 'vault')).toEqual({ key: 'vault/read_secret', server: 'vault' });
});
it('handles a bare (non-namespaced) tool name', () => {
expect(canonicalizeToolName('read_prompts', null)).toEqual({ key: 'read_prompts', server: null });
});
it('handles all/ where the tool name itself contains slashes', () => {
expect(canonicalizeToolName('all/srv/a/b', null)).toEqual({ key: 'srv/a/b', server: 'srv' });
});
it('all/<server>/<tool> and <server>/<tool> collapse to the same key', () => {
const a = canonicalizeToolName('all/gitea/create_pull_request', null);
const b = canonicalizeToolName('gitea/create_pull_request', 'gitea');
expect(a.key).toBe(b.key);
});
});