feat: mcpctl v0.0.1 — first public release
Comprehensive MCP server management with kubectl-style CLI. Key features in this release: - Declarative YAML apply/get round-trip with project cloning support - Gated sessions with prompt intelligence for Claude - Interactive MCP console with traffic inspector - Persistent STDIO connections for containerized servers - RBAC with name-scoped bindings - Shell completions (fish + bash) auto-generated - Rate-limit retry with exponential backoff in apply - Project-scoped prompt management - Credential scrubbing from git history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@ function setupGatedRouter(
|
||||
prompts?: typeof samplePrompts;
|
||||
withLlm?: boolean;
|
||||
llmResponse?: string;
|
||||
byteBudget?: number;
|
||||
} = {},
|
||||
): { router: McpRouter; mcpdClient: McpdClient } {
|
||||
const router = new McpRouter();
|
||||
@@ -101,6 +102,7 @@ function setupGatedRouter(
|
||||
router.setGateConfig({
|
||||
gated: opts.gated !== false,
|
||||
providerRegistry,
|
||||
byteBudget: opts.byteBudget,
|
||||
});
|
||||
|
||||
return { router, mcpdClient };
|
||||
@@ -309,16 +311,18 @@ describe('McpRouter gating', () => {
|
||||
});
|
||||
|
||||
it('filters out already-sent prompts', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
// Use a tight byte budget so begin_session only sends the top-scoring prompts
|
||||
const { router } = setupGatedRouter({ byteBudget: 80 });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// begin_session sends common-mistakes (priority 10) and zigbee-pairing
|
||||
// begin_session with ['zigbee'] sends common-mistakes (priority 10, Inf) and
|
||||
// zigbee-pairing (7+7=14) within 80 bytes. Lower-scored prompts overflow.
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// read_prompts for mqtt should not re-send common-mistakes
|
||||
// read_prompts for mqtt should find mqtt-config (wasn't fully sent), not re-send common-mistakes
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
|
||||
{ sessionId: 's1' },
|
||||
@@ -495,6 +499,121 @@ describe('McpRouter gating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool inventory', () => {
|
||||
it('includes tool names but NOT descriptions in gated initialize instructions', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
|
||||
router.addUpstream(mockUpstream('node-red', { tools: [{ name: 'get_flows', description: 'Get all flows' }] }));
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { instructions: string };
|
||||
expect(result.instructions).toContain('ha/get_entities');
|
||||
expect(result.instructions).toContain('node-red/get_flows');
|
||||
expect(result.instructions).toContain('after begin_session');
|
||||
// Descriptions should NOT be in init instructions (names only)
|
||||
expect(result.instructions).not.toContain('Get all entities');
|
||||
expect(result.instructions).not.toContain('Get all flows');
|
||||
});
|
||||
|
||||
it('includes tool names but NOT descriptions in begin_session response', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).toContain('ha/get_entities');
|
||||
expect(text).not.toContain('Get all entities');
|
||||
});
|
||||
|
||||
it('includes retry instruction in begin_session response', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).toContain('Proceed with');
|
||||
});
|
||||
|
||||
it('includes tool names but NOT descriptions in gated intercept briefing', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] });
|
||||
router.addUpstream(ha);
|
||||
await router.discoverTools();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { content: Array<{ type: string; text: string }> };
|
||||
const briefing = result.content[0]!.text;
|
||||
expect(briefing).toContain('ha/get_entities');
|
||||
expect(briefing).not.toContain('Get all entities');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifications after ungating', () => {
|
||||
it('queues tools/list_changed after begin_session ungating', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const notifications = router.consumeNotifications('s1');
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
|
||||
});
|
||||
|
||||
it('queues tools/list_changed after gated intercept', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
|
||||
router.addUpstream(ha);
|
||||
await router.discoverTools();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const notifications = router.consumeNotifications('s1');
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
|
||||
});
|
||||
|
||||
it('consumeNotifications clears the queue', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// First consume returns the notification
|
||||
expect(router.consumeNotifications('s1')).toHaveLength(1);
|
||||
// Second consume returns empty
|
||||
expect(router.consumeNotifications('s1')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt index caching', () => {
|
||||
it('caches prompt index for 60 seconds', async () => {
|
||||
const { router, mcpdClient } = setupGatedRouter({ gated: false });
|
||||
@@ -517,4 +636,216 @@ describe('McpRouter gating', () => {
|
||||
expect(getCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('begin_session description field', () => {
|
||||
it('accepts description and tokenizes to keywords', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: 'I want to pair a zigbee device with mqtt' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Should match zigbee-pairing and mqtt-config via tokenized keywords
|
||||
expect(text).toContain('zigbee-pairing');
|
||||
expect(text).toContain('mqtt-config');
|
||||
});
|
||||
|
||||
it('prefers tags over description when both provided', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'], description: 'zigbee pairing' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Tags take priority — mqtt-config should match, zigbee-pairing should not
|
||||
expect(text).toContain('mqtt-config');
|
||||
});
|
||||
|
||||
it('rejects calls with neither tags nor description', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeDefined();
|
||||
expect(res.error!.code).toBe(-32602);
|
||||
expect(res.error!.message).toContain('tags or description');
|
||||
});
|
||||
|
||||
it('rejects empty description with no tags', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: ' ' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeDefined();
|
||||
expect(res.error!.code).toBe(-32602);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate config refresh', () => {
|
||||
it('new sessions pick up gate config change (gated → ungated)', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// First session is gated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
let toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
|
||||
// Project config changes: gated → ungated
|
||||
router.setGateConfig({ gated: false, providerRegistry: null });
|
||||
|
||||
// New session should be ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
|
||||
toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 4, method: 'tools/list' },
|
||||
{ sessionId: 's2' },
|
||||
);
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
|
||||
it('new sessions pick up gate config change (ungated → gated)', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// First session is ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
let toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
let names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
|
||||
// Project config changes: ungated → gated
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
|
||||
// New session should be gated
|
||||
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
|
||||
toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 4, method: 'tools/list' },
|
||||
{ sessionId: 's2' },
|
||||
);
|
||||
names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toHaveLength(1);
|
||||
expect(names[0]).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('existing sessions retain gate state after config change', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session created while gated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Config changes to ungated
|
||||
router.setGateConfig({ gated: false, providerRegistry: null });
|
||||
|
||||
// Existing session s1 should STILL be gated (session state is immutable after creation)
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('already-ungated sessions remain ungated after config changes to gated', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session created while ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Config changes to gated
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
|
||||
// Existing session s1 should remain ungated
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
|
||||
it('config refresh does not reset sessions that ungated via begin_session', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session starts gated and ungates
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// Config refreshes (still gated)
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
|
||||
// Session should remain ungated — begin_session already completed
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response size cap', () => {
|
||||
it('truncates begin_session response over 24K chars', async () => {
|
||||
// Create prompts with very large content to exceed 24K
|
||||
// Use byteBudget large enough so content is included in fullContent
|
||||
const largePrompts = [
|
||||
{ name: 'huge-prompt', priority: 10, summary: 'A very large prompt', chapters: null, content: 'x'.repeat(30_000) },
|
||||
];
|
||||
const { router } = setupGatedRouter({ prompts: largePrompts, byteBudget: 50_000 });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['huge'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text.length).toBeLessThanOrEqual(24_000 + 100); // allow for truncation message
|
||||
expect(text).toContain('[Response truncated');
|
||||
});
|
||||
|
||||
it('does not truncate responses under 24K chars', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).not.toContain('[Response truncated');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, type PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription, type PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
|
||||
function makePrompt(overrides: Partial<PromptIndexEntry> = {}): PromptIndexEntry {
|
||||
return {
|
||||
@@ -13,22 +13,23 @@ function makePrompt(overrides: Partial<PromptIndexEntry> = {}): PromptIndexEntry
|
||||
}
|
||||
|
||||
describe('TagMatcher', () => {
|
||||
it('returns priority 10 prompts regardless of tags', () => {
|
||||
it('returns priority 10 prompts first, then others by priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const critical = makePrompt({ name: 'common-mistakes', priority: 10, summary: 'Unrelated stuff' });
|
||||
const normal = makePrompt({ name: 'normal', priority: 5, summary: 'Something else' });
|
||||
|
||||
const result = matcher.match([], [critical, normal]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['common-mistakes']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['normal']);
|
||||
// Both included — priority 10 first (Infinity), then priority 5 (baseline 5)
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['common-mistakes', 'normal']);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('scores by matching_tags * priority', () => {
|
||||
it('scores by priority baseline + matching_tags * priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const high = makePrompt({ name: 'important', priority: 8, summary: 'zigbee mqtt pairing' });
|
||||
const low = makePrompt({ name: 'basic', priority: 3, summary: 'zigbee basics' });
|
||||
|
||||
// Both match "zigbee": high scores 1*8=8, low scores 1*3=3
|
||||
// high: 8 + 1*8 = 16, low: 3 + 1*3 = 6
|
||||
const result = matcher.match(['zigbee'], [low, high]);
|
||||
expect(result.fullContent[0]!.name).toBe('important');
|
||||
expect(result.fullContent[1]!.name).toBe('basic');
|
||||
@@ -39,7 +40,7 @@ describe('TagMatcher', () => {
|
||||
const twoMatch = makePrompt({ name: 'two-match', priority: 5, summary: 'zigbee mqtt' });
|
||||
const oneMatch = makePrompt({ name: 'one-match', priority: 5, summary: 'zigbee only' });
|
||||
|
||||
// two-match: 2*5=10, one-match: 1*5=5
|
||||
// two-match: 5 + 2*5 = 15, one-match: 5 + 1*5 = 10
|
||||
const result = matcher.match(['zigbee', 'mqtt'], [oneMatch, twoMatch]);
|
||||
expect(result.fullContent[0]!.name).toBe('two-match');
|
||||
});
|
||||
@@ -72,24 +73,50 @@ describe('TagMatcher', () => {
|
||||
expect(result.indexOnly.map((p) => p.name)).toEqual(['big']);
|
||||
});
|
||||
|
||||
it('puts non-matched prompts in remaining', () => {
|
||||
it('includes all prompts — tag-matched ranked higher', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const matched = makePrompt({ name: 'matched', summary: 'zigbee stuff' });
|
||||
const unmatched = makePrompt({ name: 'unmatched', summary: 'completely different topic' });
|
||||
|
||||
const result = matcher.match(['zigbee'], [matched, unmatched]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['matched']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['unmatched']);
|
||||
// matched: 5 + 1*5 = 10, unmatched: 5 + 0 = 5 — both included, matched first
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['matched', 'unmatched']);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty tags — only priority 10 matched', () => {
|
||||
it('handles empty tags — all prompts included by priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const critical = makePrompt({ name: 'critical', priority: 10 });
|
||||
const normal = makePrompt({ name: 'normal', priority: 5 });
|
||||
|
||||
const result = matcher.match([], [critical, normal]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['critical']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['normal']);
|
||||
// priority 10 → Infinity, priority 5 → baseline 5
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['critical', 'normal']);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes unrelated prompts within byte budget (priority baseline)', () => {
|
||||
const matcher = new TagMatcher(500);
|
||||
const related = makePrompt({ name: 'node-red-flows', priority: 5, summary: 'node-red flow management' });
|
||||
const unrelated = makePrompt({ name: 'stack', priority: 5, summary: 'project stack overview', content: 'Tech stack info...' });
|
||||
|
||||
// Tags match "node-red-flows" but not "stack" — both should be included
|
||||
const result = matcher.match(['node-red', 'flows'], [related, unrelated]);
|
||||
expect(result.fullContent.map((p) => p.name)).toContain('stack');
|
||||
expect(result.fullContent.map((p) => p.name)).toContain('node-red-flows');
|
||||
// Related prompt should be ranked higher
|
||||
expect(result.fullContent[0]!.name).toBe('node-red-flows');
|
||||
});
|
||||
|
||||
it('pushes low-priority unrelated prompts to indexOnly when budget is tight', () => {
|
||||
const matcher = new TagMatcher(100);
|
||||
const related = makePrompt({ name: 'related', priority: 5, summary: 'zigbee', content: 'x'.repeat(80) });
|
||||
const unrelated = makePrompt({ name: 'unrelated', priority: 3, summary: 'other', content: 'y'.repeat(80) });
|
||||
|
||||
const result = matcher.match(['zigbee'], [related, unrelated]);
|
||||
// related: 5 + 1*5 = 10 (higher score, fits budget), unrelated: 3 + 0 = 3 (overflow)
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['related']);
|
||||
expect(result.indexOnly.map((p) => p.name)).toEqual(['unrelated']);
|
||||
});
|
||||
|
||||
it('handles empty prompts array', () => {
|
||||
@@ -115,12 +142,13 @@ describe('TagMatcher', () => {
|
||||
|
||||
it('sorts matched by score descending', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const p1 = makePrompt({ name: 'p1', priority: 3, summary: 'mqtt zigbee lights' }); // 3 matches * 3 = 9
|
||||
const p2 = makePrompt({ name: 'p2', priority: 8, summary: 'mqtt' }); // 1 match * 8 = 8
|
||||
const p3 = makePrompt({ name: 'p3', priority: 2, summary: 'mqtt zigbee lights pairing automation' }); // 5 * 2 = 10
|
||||
const p1 = makePrompt({ name: 'p1', priority: 3, summary: 'mqtt zigbee lights' }); // 3 + 3*3 = 12
|
||||
const p2 = makePrompt({ name: 'p2', priority: 8, summary: 'mqtt' }); // 8 + 1*8 = 16
|
||||
const p3 = makePrompt({ name: 'p3', priority: 2, summary: 'mqtt zigbee lights pairing automation' }); // 2 + 5*2 = 12
|
||||
|
||||
const result = matcher.match(['mqtt', 'zigbee', 'lights', 'pairing', 'automation'], [p1, p2, p3]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['p3', 'p1', 'p2']);
|
||||
// p2 (16) > p1 (12) = p3 (12), tie-break by input order
|
||||
expect(result.fullContent[0]!.name).toBe('p2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,3 +191,67 @@ describe('extractKeywordsFromToolCall', () => {
|
||||
expect(keywords).toContain('mqtt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeDescription', () => {
|
||||
it('extracts meaningful words from a sentence', () => {
|
||||
const result = tokenizeDescription('I want to get node-red flows');
|
||||
expect(result).toContain('node-red');
|
||||
expect(result).toContain('flows');
|
||||
});
|
||||
|
||||
it('filters stop words', () => {
|
||||
const result = tokenizeDescription('I want to get the flows for my project');
|
||||
expect(result).not.toContain('want');
|
||||
expect(result).not.toContain('the');
|
||||
expect(result).not.toContain('for');
|
||||
expect(result).toContain('flows');
|
||||
expect(result).toContain('project');
|
||||
});
|
||||
|
||||
it('filters words shorter than 3 characters', () => {
|
||||
const result = tokenizeDescription('go to my HA setup');
|
||||
expect(result).not.toContain('go');
|
||||
expect(result).not.toContain('to');
|
||||
expect(result).not.toContain('my');
|
||||
expect(result).not.toContain('ha');
|
||||
expect(result).toContain('setup');
|
||||
});
|
||||
|
||||
it('lowercases all tokens', () => {
|
||||
const result = tokenizeDescription('Configure MQTT Broker Settings');
|
||||
expect(result).toContain('configure');
|
||||
expect(result).toContain('mqtt');
|
||||
expect(result).toContain('broker');
|
||||
expect(result).toContain('settings');
|
||||
});
|
||||
|
||||
it('caps at 10 keywords', () => {
|
||||
const result = tokenizeDescription(
|
||||
'alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa',
|
||||
);
|
||||
expect(result.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('deduplicates words', () => {
|
||||
const result = tokenizeDescription('zigbee zigbee zigbee pairing');
|
||||
expect(result.filter((w) => w === 'zigbee')).toHaveLength(1);
|
||||
expect(result).toContain('pairing');
|
||||
});
|
||||
|
||||
it('handles punctuation and special characters', () => {
|
||||
const result = tokenizeDescription('home-assistant; mqtt/broker (setup)');
|
||||
// Hyphens are preserved within words (compound names)
|
||||
expect(result).toContain('home-assistant');
|
||||
expect(result).toContain('mqtt');
|
||||
expect(result).toContain('broker');
|
||||
expect(result).toContain('setup');
|
||||
});
|
||||
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(tokenizeDescription('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for only stop words', () => {
|
||||
expect(tokenizeDescription('I want to get the')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user