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

@@ -65,6 +65,16 @@ export async function refreshProjectUpstreams(
* in `~/.mcpctl/config.json` still take priority, and unknown names fall
* through to the registry default.
*/
/**
* Two-namespace `favourite/` + `all/` tool presentation config (per-project).
* `tools` are canonical `server/tool` pins shown as favourites, in order.
*/
export interface FavouriteIndexConfig {
enabled?: boolean;
tools?: string[];
maxFavourites?: number;
}
export interface ProjectLlmConfig {
/** Name of an `Llm` resource on mcpd, or 'none' to disable LLM features. */
llmProvider?: string;
@@ -72,6 +82,7 @@ export interface ProjectLlmConfig {
proxyModel?: string;
gated?: boolean;
serverOverrides?: Record<string, { proxyModel?: string }>;
favouriteIndex?: FavouriteIndexConfig;
}
/**
@@ -110,6 +121,7 @@ export async function fetchProjectLlmConfig(
proxyModel?: string;
gated?: boolean;
serverOverrides?: Record<string, { proxyModel?: string }>;
favouriteIndex?: FavouriteIndexConfig;
}>(`/api/v1/projects/${encodeURIComponent(projectName)}`);
const config: ProjectLlmConfig = {};
if (project.llmProvider) config.llmProvider = project.llmProvider;
@@ -125,6 +137,7 @@ export async function fetchProjectLlmConfig(
}
config.gated = config.proxyModel === 'default' || config.proxyModel === 'gate';
if (project.serverOverrides) config.serverOverrides = project.serverOverrides;
if (project.favouriteIndex) config.favouriteIndex = project.favouriteIndex;
return config;
} catch {
return {};

View File

@@ -23,7 +23,9 @@ import { LLMProviderAdapter } from '../proxymodel/llm-adapter.js';
import { FileCache } from '../proxymodel/file-cache.js';
import { createDefaultPlugin } from '../proxymodel/plugins/default.js';
import { createAgentsPlugin } from '../proxymodel/plugins/agents.js';
import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-index.js';
import { composePlugins } from '../proxymodel/plugins/compose.js';
import type { ProxyModelPlugin } from '../proxymodel/plugin.js';
import { AuditCollector } from '../audit/collector.js';
interface ProjectCacheEntry {
@@ -146,11 +148,25 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
};
if (resolvedModel) pluginConfig.modelOverride = resolvedModel;
const basePlugin = createDefaultPlugin(pluginConfig);
// Optional favourite-index presentation: curated favourite/<tool> + full
// all/<server>/<tool> + a "prefer favourite/" instruction. Composed AFTER
// the base (gate) plugin so it reshapes the ungated catalog and no-ops
// while gated. Only when the project opts in and has pinned tools.
const fav = mcpdConfig.favouriteIndex;
const chain: ProxyModelPlugin[] = [basePlugin];
if (fav?.enabled && (fav.tools?.length ?? 0) > 0) {
chain.push(
createFavouriteIndexPlugin({
favourites: fav.tools ?? [],
...(fav.maxFavourites !== undefined ? { maxFavourites: fav.maxFavourites } : {}),
}),
);
}
// Always compose the agents plugin on top so Agents attached to the
// project show up as virtual MCP servers in tools/list, regardless of
// which proxymodel the project is using.
const plugin = composePlugins([basePlugin, createAgentsPlugin()]);
router.setPlugin(plugin);
chain.push(createAgentsPlugin());
router.setPlugin(composePlugins(chain));
// Fetch project instructions and set on router
try {

View File

@@ -10,7 +10,8 @@
*
* Hook semantics:
* - onSessionCreate / onSessionDestroy: every plugin's hook runs in order.
* - onInitialize: first non-null result wins (instructions don't merge).
* - onInitialize: instructions from every plugin are concatenated (in order,
* blank-line separated) so orthogonal plugins can each contribute a banner.
* - onToolsList / onResourcesList / onPromptsList: results pipeline through
* the plugins, each transforming the previous step's output.
* - onToolCallBefore / onResourceRead / onPromptGet: first non-null wins
@@ -50,13 +51,15 @@ export function composePlugins(plugins: ProxyModelPlugin[]): ProxyModelPlugin {
}
if (plugins.some((p) => p.onInitialize)) {
out.onInitialize = async (request, ctx) => {
const parts: string[] = [];
for (const p of plugins) {
if (p.onInitialize) {
const res = await p.onInitialize(request, ctx);
if (res !== null) return res;
const instr = res?.instructions?.trim();
if (instr) parts.push(instr);
}
}
return null;
return parts.length > 0 ? { instructions: parts.join('\n\n') } : null;
};
}
if (plugins.some((p) => p.onToolsList)) {

View File

@@ -0,0 +1,139 @@
/**
* favourite-index plugin — two-namespace tool presentation.
*
* Measured on a 145-tool catalog (scripts/model-eval/toolsim.py) to be the tool
* presentation that most reduces wander and lifts first-pick for a wandering
* model: expose a curated `favourite/<tool>` shortlist FIRST, the full
* `all/<server>/<tool>` catalog second, plus a load-bearing instruction telling
* the model to prefer favourites and fall back to `all/` only when nothing fits.
* (The instruction is not optional — the same two namespaces without it scored
* materially worse. See memory `feedback_llm_tool_scoping`.)
*
* Composes AFTER the gate/default plugin: it transforms the *ungated* upstream
* catalog and no-ops while a session is still gated (only `begin_session`
* visible). Gate/agent virtual tools (read_prompts, propose_prompt,
* agent-<name> chat) pass through unchanged at the top level.
*
* Routing: the presented names are re-mapped back to their canonical
* `server/tool` in `onToolCallBefore` (a name rewrite, then the chain
* continues) so the normal upstream routing AND the content-pipeline
* `onToolCallAfter` still run — unlike a virtual-tool alias, which would
* short-circuit before content processing.
*/
import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js';
import type { ToolDefinition } from '../types.js';
/** Per-session state key holding the presented-name → canonical-name map. */
const RESOLVER_KEY = 'favourite-index:resolver';
/** The load-bearing "prefer favourite/" instruction (see module doc). */
export const FAVOURITE_INDEX_INSTRUCTION =
'Tools are indexed in two namespaces. PREFER favourite/<tool> — a short ' +
'curated list of the common tools that covers most tasks; reach for these ' +
'first. Use the full catalog under all/<server>/<tool> only if nothing in ' +
"favourites fits. (read_prompts gives this project's own guidance.)";
export interface FavouriteIndexConfig {
/** Canonical `server/tool` names to surface as favourites, in display order. */
favourites: string[];
/** Cap on favourites presented (default: all provided). Enforces "not everything". */
maxFavourites?: number;
}
function humanize(name: string): string {
return name.replace(/_/g, ' ');
}
/** Split a canonical `server/tool` on the FIRST slash (server names have none). */
function splitServer(canonical: string): { server: string; short: string } {
const i = canonical.indexOf('/');
return i < 0
? { server: '', short: canonical }
: { server: canonical.slice(0, i), short: canonical.slice(i + 1) };
}
export function createFavouriteIndexPlugin(config: FavouriteIndexConfig): ProxyModelPlugin {
const favourites = config.favourites ?? [];
const cap = config.maxFavourites && config.maxFavourites > 0 ? config.maxFavourites : undefined;
return {
name: 'favourite-index',
description:
'Two-namespace tool presentation: curated favourite/<tool> + full all/<server>/<tool>, prefer favourites.',
async onInitialize() {
return { instructions: FAVOURITE_INDEX_INSTRUCTION };
},
async onToolsList(tools: ToolDefinition[], ctx: PluginSessionContext): Promise<ToolDefinition[]> {
// Distinguish upstream tools (in the discovered catalog) from virtual
// tools (gate/agent) that must pass through untouched.
const upstream = await ctx.discoverTools();
const upstreamNames = new Set(upstream.map((t) => t.name));
const upstreamTools = tools.filter((t) => upstreamNames.has(t.name));
const passthrough = tools.filter((t) => !upstreamNames.has(t.name));
// Gated / empty catalog → no-op (leave the list exactly as gate produced it).
if (upstreamTools.length === 0) {
ctx.state.delete(RESOLVER_KEY);
return tools;
}
const byName = new Map(upstreamTools.map((t) => [t.name, t]));
const resolver = new Map<string, string>(); // presented → canonical
const usedPresented = new Set<string>();
const seenCanonical = new Set<string>();
// Favourites first: configured order, only those present, capped, with
// short-name collision disambiguation.
const limit = cap ?? favourites.length;
const favTools: ToolDefinition[] = [];
for (const canonical of favourites) {
if (favTools.length >= limit) break;
if (seenCanonical.has(canonical)) continue;
const t = byName.get(canonical);
if (!t) continue; // stale/missing pin — skip (all/ still exposes it)
const { server, short } = splitServer(canonical);
let presented = `favourite/${short}`;
if (usedPresented.has(presented)) presented = `favourite/${server}-${short}`;
if (usedPresented.has(presented)) continue; // still collides (rare) — skip
seenCanonical.add(canonical);
usedPresented.add(presented);
resolver.set(presented, canonical);
favTools.push({
name: presented,
description: `${humanize(short)} — common (${server})`,
...(t.inputSchema !== undefined ? { inputSchema: t.inputSchema } : {}),
});
}
// Then the full catalog under all/<server>/<tool>.
const allTools: ToolDefinition[] = upstreamTools.map((t) => {
const presented = `all/${t.name}`;
resolver.set(presented, t.name);
return {
name: presented,
...(t.description !== undefined ? { description: t.description } : {}),
...(t.inputSchema !== undefined ? { inputSchema: t.inputSchema } : {}),
};
});
ctx.state.set(RESOLVER_KEY, resolver);
return [...favTools, ...allTools, ...passthrough];
},
async onToolCallBefore(toolName, _args, request, ctx) {
const resolver = ctx.state.get(RESOLVER_KEY) as Map<string, string> | undefined;
const canonical =
resolver?.get(toolName) ??
(toolName.startsWith('all/') ? toolName.slice('all/'.length) : undefined);
if (canonical && canonical !== toolName) {
// Rewrite presented name → canonical `server/tool`, then continue the
// chain (return null) so normal routing + content-pipeline still run.
const params = request.params as Record<string, unknown> | undefined;
if (params) params['name'] = canonical;
}
return null;
},
};
}

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

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