feat: mcpctl v0.0.1 — first public release
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions

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:
Michal
2026-02-27 17:05:05 +00:00
parent 414a8d3774
commit 69867bd47a
65 changed files with 5710 additions and 695 deletions

View File

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

View File

@@ -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([]);
});
});