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:
185
src/mcplocal/tests/plugin-favourite-index.test.ts
Normal file
185
src/mcplocal/tests/plugin-favourite-index.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* favourite-index plugin tests — presentation (favourite/ + all/), rewrite
|
||||
* routing back to canonical server/tool, gated no-op, and instruction merge.
|
||||
*
|
||||
* Driven through a real McpRouter (gate composed with favourite-index, mirroring
|
||||
* project-mcp-endpoint wiring) so routing is exercised end-to-end.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { McpRouter } from '../src/router.js';
|
||||
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
|
||||
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||
import { createGatePlugin } from '../src/proxymodel/plugins/gate.js';
|
||||
import { composePlugins } from '../src/proxymodel/plugins/compose.js';
|
||||
import {
|
||||
createFavouriteIndexPlugin,
|
||||
FAVOURITE_INDEX_INSTRUCTION,
|
||||
} from '../src/proxymodel/plugins/favourite-index.js';
|
||||
import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
|
||||
import { MemoryCache } from '../src/proxymodel/cache.js';
|
||||
|
||||
function mockUpstream(
|
||||
name: string,
|
||||
tools: Array<{ name: string; description?: string }>,
|
||||
): UpstreamConnection {
|
||||
return {
|
||||
name,
|
||||
isAlive: vi.fn(() => true),
|
||||
close: vi.fn(async () => {}),
|
||||
onNotification: vi.fn(),
|
||||
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
||||
if (req.method === 'tools/list') {
|
||||
return { jsonrpc: '2.0', id: req.id, result: { tools } };
|
||||
}
|
||||
if (req.method === 'tools/call') {
|
||||
// Echo the name the UPSTREAM received (proves canonical routing).
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: { content: [{ type: 'text', text: `Called ${(req.params as Record<string, unknown>)?.['name']}` }] },
|
||||
};
|
||||
}
|
||||
if (req.method === 'resources/list') return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
|
||||
if (req.method === 'prompts/list') return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } };
|
||||
return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
|
||||
}),
|
||||
} as UpstreamConnection;
|
||||
}
|
||||
|
||||
function mockMcpdClient(): McpdClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
forward: vi.fn(async () => ({ status: 200, body: {} })),
|
||||
withHeaders: vi.fn(function (this: McpdClient) { return this; }),
|
||||
} as unknown as McpdClient;
|
||||
}
|
||||
|
||||
function setup(opts: { gated?: boolean; favourites?: string[]; maxFavourites?: number } = {}) {
|
||||
const router = new McpRouter();
|
||||
router.setPromptConfig(mockMcpdClient(), 'test-project');
|
||||
const plugin = composePlugins([
|
||||
createGatePlugin({ gated: opts.gated ?? false, providerRegistry: null }),
|
||||
createFavouriteIndexPlugin({
|
||||
favourites: opts.favourites ?? [],
|
||||
...(opts.maxFavourites !== undefined ? { maxFavourites: opts.maxFavourites } : {}),
|
||||
}),
|
||||
]);
|
||||
router.setPlugin(plugin);
|
||||
router.setProxyModel('default', { complete: async () => '', available: () => false } as unknown as LLMProviderAdapter, new MemoryCache());
|
||||
return router;
|
||||
}
|
||||
|
||||
async function initAndList(router: McpRouter, sessionId = 's1'): Promise<Array<{ name: string; description?: string }>> {
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId });
|
||||
const res = await router.route({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, { sessionId });
|
||||
return (res.result as { tools: Array<{ name: string; description?: string }> }).tools;
|
||||
}
|
||||
|
||||
const CATALOG = {
|
||||
k8s: [{ name: 'get_pods' }, { name: 'get_secret' }, { name: 'scale_deployment' }],
|
||||
vault: [{ name: 'read_secret' }, { name: 'list_secrets' }],
|
||||
};
|
||||
|
||||
describe('favourite-index plugin', () => {
|
||||
it('presents favourite/ first (curated) then all/ (full catalog)', async () => {
|
||||
const router = setup({ favourites: ['k8s/get_pods', 'vault/read_secret'] });
|
||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||
router.addUpstream(mockUpstream('vault', CATALOG.vault));
|
||||
|
||||
const names = (await initAndList(router)).map((t) => t.name);
|
||||
|
||||
// Favourites, in configured order, appear before any all/ entry.
|
||||
expect(names.indexOf('favourite/get_pods')).toBe(0);
|
||||
expect(names.indexOf('favourite/read_secret')).toBe(1);
|
||||
expect(names.indexOf('favourite/get_pods')).toBeLessThan(names.indexOf('all/k8s/get_pods'));
|
||||
|
||||
// Full catalog present under all/.
|
||||
for (const t of [...CATALOG.k8s.map((x) => `all/k8s/${x.name}`), ...CATALOG.vault.map((x) => `all/vault/${x.name}`)]) {
|
||||
expect(names).toContain(t);
|
||||
}
|
||||
// Gate virtual tools pass through untouched (not re-namespaced).
|
||||
expect(names).toContain('read_prompts');
|
||||
expect(names).not.toContain('favourite/read_prompts');
|
||||
// Non-favourite tools are NOT duplicated into favourite/.
|
||||
expect(names).not.toContain('favourite/get_secret');
|
||||
});
|
||||
|
||||
it('routes a favourite/ call back to the canonical upstream tool', async () => {
|
||||
const router = setup({ favourites: ['vault/read_secret'] });
|
||||
router.addUpstream(mockUpstream('vault', CATALOG.vault));
|
||||
await initAndList(router);
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'favourite/read_secret', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Upstream sees the stripped canonical tool name.
|
||||
expect(text).toBe('Called read_secret');
|
||||
});
|
||||
|
||||
it('routes an all/<server>/<tool> call back to the canonical upstream tool', async () => {
|
||||
const router = setup({ favourites: [] });
|
||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||
await initAndList(router);
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'all/k8s/scale_deployment', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).toBe('Called scale_deployment');
|
||||
});
|
||||
|
||||
it('no-ops while the session is gated (only begin_session visible)', async () => {
|
||||
const router = setup({ gated: true, favourites: ['k8s/get_pods'] });
|
||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||
const names = (await initAndList(router)).map((t) => t.name);
|
||||
expect(names).toEqual(['begin_session']);
|
||||
});
|
||||
|
||||
it('skips stale favourites not in the catalog but keeps them in all/', async () => {
|
||||
const router = setup({ favourites: ['k8s/get_pods', 'gone/missing_tool'] });
|
||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||
const names = (await initAndList(router)).map((t) => t.name);
|
||||
expect(names).toContain('favourite/get_pods');
|
||||
expect(names).not.toContain('favourite/missing_tool');
|
||||
});
|
||||
|
||||
it('caps favourites at maxFavourites', async () => {
|
||||
const router = setup({ favourites: ['k8s/get_pods', 'k8s/get_secret', 'vault/read_secret'], maxFavourites: 2 });
|
||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||
router.addUpstream(mockUpstream('vault', CATALOG.vault));
|
||||
const names = (await initAndList(router)).map((t) => t.name).filter((n) => n.startsWith('favourite/'));
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names).toEqual(['favourite/get_pods', 'favourite/get_secret']);
|
||||
});
|
||||
|
||||
it('disambiguates short-name collisions across servers', async () => {
|
||||
// Both servers expose a `status` tool; both are favourites.
|
||||
const router = setup({ favourites: ['k8s/status', 'vault/status'] });
|
||||
router.addUpstream(mockUpstream('k8s', [{ name: 'status' }]));
|
||||
router.addUpstream(mockUpstream('vault', [{ name: 'status' }]));
|
||||
const names = (await initAndList(router)).map((t) => t.name).filter((n) => n.startsWith('favourite/'));
|
||||
expect(names).toContain('favourite/status');
|
||||
expect(names).toContain('favourite/vault-status');
|
||||
|
||||
// The disambiguated favourite still routes to the right server.
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'favourite/vault-status', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((res.result as { content: Array<{ text: string }> }).content[0]!.text).toBe('Called status');
|
||||
});
|
||||
|
||||
it('injects the load-bearing prefer-favourite instruction on initialize', async () => {
|
||||
const router = setup({ favourites: ['k8s/get_pods'] });
|
||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||
const res = await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
const instructions = (res.result as { instructions?: string }).instructions ?? '';
|
||||
expect(instructions).toContain(FAVOURITE_INDEX_INSTRUCTION);
|
||||
});
|
||||
});
|
||||
125
src/mcplocal/tests/smoke/favourite-index.test.ts
Normal file
125
src/mcplocal/tests/smoke/favourite-index.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Smoke test: favourite-index tool presentation end-to-end.
|
||||
*
|
||||
* Provisions an ungated project with favourite-index enabled + two pinned tools
|
||||
* from the smoke-aws-docs server, then verifies through the LIVE mcplocal proxy:
|
||||
* - initialize instructions carry the load-bearing "prefer favourite/" line,
|
||||
* - tools/list presents favourite/<tool> (curated) + all/<server>/<tool> (full),
|
||||
* - a favourite/ call and an all/ call both ROUTE to the real upstream
|
||||
* (they reach the server's arg validation, not a -32601 "unknown tool").
|
||||
*
|
||||
* Run with: pnpm test:smoke
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { SmokeMcpSession, isMcplocalRunning, mcpctl } from './mcp-client.js';
|
||||
import { ChatReporter } from './reporter.js';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const PROJECT_NAME = 'smoke-favindex';
|
||||
const SMOKE_DATA = 'smoke-data';
|
||||
const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures', 'smoke-data.yaml');
|
||||
const FAV_TOOL = 'smoke-aws-docs/read_documentation';
|
||||
|
||||
describe('Smoke: favourite-index presentation', () => {
|
||||
let ready = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
console.log('\n ━━━ Smoke Test: favourite-index ━━━');
|
||||
if (!(await isMcplocalRunning())) {
|
||||
console.log(' ✗ mcplocal not running — skipping\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the shared smoke-aws-docs server exists (from the smoke-data fixture).
|
||||
try {
|
||||
await mcpctl(`describe project ${SMOKE_DATA}`);
|
||||
} catch {
|
||||
try { await mcpctl(`apply -f ${FIXTURE_PATH}`); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Dedicated ungated project with favourite-index on + two pins.
|
||||
try {
|
||||
await mcpctl(
|
||||
`create project ${PROJECT_NAME} --force --no-gated --server smoke-aws-docs ` +
|
||||
`--favourite-index --favourite ${FAV_TOOL} --favourite smoke-aws-docs/search_documentation`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(` ⚠ project setup error: ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const preflight = new SmokeMcpSession(PROJECT_NAME);
|
||||
try {
|
||||
await preflight.initialize();
|
||||
ready = true;
|
||||
console.log(' ✓ Server responding');
|
||||
} catch (err) {
|
||||
console.log(` ✗ Server not responding: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
await preflight.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
try { await mcpctl(`delete project ${PROJECT_NAME}`); } catch { /* best effort cleanup */ }
|
||||
console.log('\n ━━━ favourite-index smoke complete ━━━\n');
|
||||
});
|
||||
|
||||
it('presents favourite/ + all/ namespaces with the prefer-favourite instruction', async () => {
|
||||
if (!ready) return;
|
||||
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
|
||||
chat.section('favourite-index presentation');
|
||||
try {
|
||||
const initResult = (await chat.initialize()) as { instructions?: string };
|
||||
const instructions = initResult?.instructions ?? '';
|
||||
chat.check('Instruction mentions favourite/', String(instructions.includes('favourite/')), (v) => v === 'true');
|
||||
expect(instructions).toContain('favourite/');
|
||||
|
||||
const tools = await chat.listTools();
|
||||
const names = tools.map((t) => t.name);
|
||||
const favNames = names.filter((n) => n.startsWith('favourite/'));
|
||||
const allNames = names.filter((n) => n.startsWith('all/'));
|
||||
|
||||
chat.check('Has favourite/ tools', favNames.length, (v) => v >= 1);
|
||||
chat.check('Has all/ catalog', allNames.length, (v) => v >= 1);
|
||||
chat.check('favourite/read_documentation present', String(names.includes('favourite/read_documentation')), (v) => v === 'true');
|
||||
chat.check('all/smoke-aws-docs/read_documentation present', String(names.includes('all/smoke-aws-docs/read_documentation')), (v) => v === 'true');
|
||||
|
||||
// Favourites are listed before the all/ catalog.
|
||||
const firstFav = names.findIndex((n) => n.startsWith('favourite/'));
|
||||
const firstAll = names.findIndex((n) => n.startsWith('all/'));
|
||||
chat.check('favourites precede all/', String(firstFav < firstAll), (v) => v === 'true');
|
||||
|
||||
expect(names).toContain('favourite/read_documentation');
|
||||
expect(names).toContain('all/smoke-aws-docs/read_documentation');
|
||||
expect(firstFav).toBeLessThan(firstAll);
|
||||
} finally {
|
||||
await chat.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('routes favourite/ and all/ calls to the real upstream tool', async () => {
|
||||
if (!ready) return;
|
||||
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
|
||||
chat.section('favourite-index routing');
|
||||
try {
|
||||
await chat.initialize();
|
||||
|
||||
// Calling with no args → the UPSTREAM tool's arg validation fires, proving
|
||||
// the presented name routed to the real server (not a -32601 unknown tool).
|
||||
const favRes = await chat.callTool('favourite/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
|
||||
const allRes = await chat.callTool('all/smoke-aws-docs/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
|
||||
|
||||
const favStr = JSON.stringify(favRes).toLowerCase();
|
||||
const allStr = JSON.stringify(allRes).toLowerCase();
|
||||
// Reached the upstream (arg validation / real response), not "unknown tool".
|
||||
const routed = (s: string): boolean => !s.includes('-32601') && !s.includes('unknown') && !s.includes('method not found');
|
||||
chat.check('favourite/ routed to upstream', String(routed(favStr)), (v) => v === 'true');
|
||||
chat.check('all/ routed to upstream', String(routed(allStr)), (v) => v === 'true');
|
||||
expect(routed(favStr)).toBe(true);
|
||||
expect(routed(allStr)).toBe(true);
|
||||
} finally {
|
||||
await chat.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user