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

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/mcplocal",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",

View File

@@ -60,26 +60,63 @@ export class TagMatcher {
}
private computeScore(lowerTags: string[], prompt: PromptIndexEntry): number {
// Priority 10 always included
// Priority 10 always included at the top
if (prompt.priority === 10) return Infinity;
if (lowerTags.length === 0) return 0;
// Baseline score = priority (so all prompts compete for the byte budget)
// Tag matches boost the score further (matchCount * priority on top)
let boost = 0;
if (lowerTags.length > 0) {
const searchText = [
prompt.name,
prompt.summary ?? '',
...(prompt.chapters ?? []),
].join(' ').toLowerCase();
const searchText = [
prompt.name,
prompt.summary ?? '',
...(prompt.chapters ?? []),
].join(' ').toLowerCase();
let matchCount = 0;
for (const tag of lowerTags) {
if (searchText.includes(tag)) matchCount++;
for (const tag of lowerTags) {
if (searchText.includes(tag)) boost++;
}
boost *= prompt.priority;
}
return matchCount * prompt.priority;
return prompt.priority + boost;
}
}
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'to', 'for', 'of', 'and', 'or', 'in', 'on', 'at',
'by', 'with', 'from', 'this', 'that', 'it', 'its', 'as', 'be', 'are', 'was',
'were', 'been', 'has', 'have', 'had', 'do', 'does', 'did', 'but', 'not',
'can', 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'must',
'so', 'if', 'then', 'than', 'too', 'very', 'just', 'about', 'up', 'out',
'no', 'yes', 'all', 'any', 'some', 'my', 'your', 'our', 'their', 'what',
'which', 'who', 'how', 'when', 'where', 'why', 'want', 'need', 'get', 'set',
'use', 'like', 'make', 'know', 'help', 'try',
]);
/**
* Convert a natural-language description into keyword tags.
* Splits on whitespace/punctuation, lowercases, filters stop words and short words, caps at 10.
*/
export function tokenizeDescription(description: string): string[] {
const words = description
.toLowerCase()
.split(/[\s.,;:!?'"()\[\]{}<>|/\\@#$%^&*+=~`]+/)
.map((w) => w.replace(/[^a-z0-9-]/g, ''))
.filter((w) => w.length >= 3 && !STOP_WORDS.has(w));
// Deduplicate while preserving order
const seen = new Set<string>();
const unique: string[] = [];
for (const w of words) {
if (!seen.has(w)) {
seen.add(w);
unique.push(w);
}
}
return unique.slice(0, 10);
}
/**
* Extract keywords from a tool call for the intercept fallback path.
* Pulls words from the tool name and string argument values.

View File

@@ -0,0 +1,82 @@
/**
* SSE endpoint for the MCP traffic inspector.
*
* GET /inspect?project=X&session=Y
*
* Streams TrafficEvents as SSE data lines. On connect, sends a snapshot
* of active sessions and recent buffered events, then streams live.
*/
import type { FastifyInstance } from 'fastify';
import type { TrafficCapture, TrafficFilter } from './traffic.js';
export function registerInspectEndpoint(app: FastifyInstance, capture: TrafficCapture): void {
app.get<{
Querystring: { project?: string; session?: string };
}>('/inspect', async (request, reply) => {
const filter: TrafficFilter = {
project: request.query.project,
session: request.query.session,
};
// Set SSE headers
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
// Send active sessions snapshot
const sessions = capture.getActiveSessions();
const filteredSessions = filter.project
? sessions.filter((s) => s.projectName === filter.project)
: sessions;
reply.raw.write(`event: sessions\ndata: ${JSON.stringify(filteredSessions)}\n\n`);
// Send buffered events
const buffered = capture.getBuffer(filter);
for (const event of buffered) {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
}
// Flush marker so client knows history is done
reply.raw.write(`event: live\ndata: {}\n\n`);
// Subscribe to live events
const matchesFilter = (e: { projectName: string; sessionId: string }): boolean => {
if (filter.project && e.projectName !== filter.project) return false;
if (filter.session && e.sessionId !== filter.session) return false;
return true;
};
const unsubscribe = capture.subscribe((event) => {
if (!matchesFilter(event)) return;
try {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
} catch {
unsubscribe();
}
});
// Keep-alive ping every 30s
const keepAlive = setInterval(() => {
try {
reply.raw.write(': keepalive\n\n');
} catch {
clearInterval(keepAlive);
unsubscribe();
}
}, 30_000);
// Cleanup on disconnect
request.raw.on('close', () => {
clearInterval(keepAlive);
unsubscribe();
});
// Hijack so Fastify doesn't try to send its own response
reply.hijack();
});
}

View File

@@ -18,6 +18,7 @@ import { loadProjectLlmOverride } from './config.js';
import type { McpdClient } from './mcpd-client.js';
import type { ProviderRegistry } from '../providers/registry.js';
import type { JsonRpcRequest } from '../types.js';
import type { TrafficCapture } from './traffic.js';
interface ProjectCacheEntry {
router: McpRouter;
@@ -31,7 +32,7 @@ interface SessionEntry {
const CACHE_TTL_MS = 60_000; // 60 seconds
export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null): void {
export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null, trafficCapture?: TrafficCapture | null): void {
const projectCache = new Map<string, ProjectCacheEntry>();
const sessions = new Map<string, SessionEntry>();
@@ -131,13 +132,88 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, { transport, projectName });
trafficCapture?.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: id,
eventType: 'session_created',
body: null,
});
},
});
// Wire upstream call tracing into the router
if (trafficCapture) {
router.onUpstreamCall = (info) => {
const sid = transport.sessionId ?? 'unknown';
trafficCapture.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: sid,
eventType: 'upstream_request',
method: info.method,
upstreamName: info.upstream,
body: info.request,
});
trafficCapture.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: sid,
eventType: 'upstream_response',
method: info.method,
upstreamName: info.upstream,
body: info.response,
durationMs: info.durationMs,
});
};
}
transport.onmessage = async (message: JSONRPCMessage) => {
if ('method' in message && 'id' in message) {
const requestId = message.id as string | number;
const sid = transport.sessionId ?? 'unknown';
const method = (message as { method?: string }).method;
// Capture client request
trafficCapture?.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: sid,
eventType: 'client_request',
method,
body: message,
});
const ctx = transport.sessionId ? { sessionId: transport.sessionId } : undefined;
const response = await router.route(message as unknown as JsonRpcRequest, ctx);
// Forward queued notifications BEFORE the response — the response send
// closes the POST SSE stream, so notifications must go first.
// relatedRequestId routes them onto the same SSE stream as the response.
if (transport.sessionId) {
for (const n of router.consumeNotifications(transport.sessionId)) {
trafficCapture?.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: sid,
eventType: 'client_notification',
method: (n as { method?: string }).method,
body: n,
});
await transport.send(n as unknown as JSONRPCMessage, { relatedRequestId: requestId });
}
}
// Capture client response
trafficCapture?.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: sid,
eventType: 'client_response',
method,
body: response,
});
await transport.send(response as unknown as JSONRPCMessage);
}
};
@@ -145,6 +221,13 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
transport.onclose = () => {
const id = transport.sessionId;
if (id) {
trafficCapture?.emit({
timestamp: new Date().toISOString(),
projectName,
sessionId: id,
eventType: 'session_closed',
body: null,
});
sessions.delete(id);
router.cleanupSession(id);
}

View File

@@ -7,6 +7,8 @@ import { McpdClient } from './mcpd-client.js';
import { registerProxyRoutes } from './routes/proxy.js';
import { registerMcpEndpoint } from './mcp-endpoint.js';
import { registerProjectMcpEndpoint } from './project-mcp-endpoint.js';
import { registerInspectEndpoint } from './inspect-endpoint.js';
import { TrafficCapture } from './traffic.js';
import type { McpRouter } from '../router.js';
import type { HealthMonitor } from '../health.js';
import type { TieredHealthMonitor } from '../health/tiered.js';
@@ -181,11 +183,15 @@ export async function createHttpServer(
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
registerProxyRoutes(app, mcpdClient);
// Traffic inspector
const trafficCapture = new TrafficCapture();
registerInspectEndpoint(app, trafficCapture);
// Streamable HTTP MCP protocol endpoint at /mcp
registerMcpEndpoint(app, deps.router);
// Project-scoped MCP endpoint at /projects/:projectName/mcp
registerProjectMcpEndpoint(app, mcpdClient, deps.providerRegistry);
registerProjectMcpEndpoint(app, mcpdClient, deps.providerRegistry, trafficCapture);
return app;
}

View File

@@ -0,0 +1,116 @@
/**
* Traffic capture for the MCP inspector.
*
* Records all MCP traffic flowing through mcplocal — both client-facing
* messages and internal upstream routing. Events are stored in a ring
* buffer and streamed to SSE subscribers in real-time.
*/
export type TrafficEventType =
| 'client_request'
| 'client_response'
| 'client_notification'
| 'upstream_request'
| 'upstream_response'
| 'session_created'
| 'session_closed';
export interface TrafficEvent {
timestamp: string;
projectName: string;
sessionId: string;
eventType: TrafficEventType;
method?: string | undefined;
upstreamName?: string | undefined;
body: unknown;
durationMs?: number | undefined;
}
export interface ActiveSession {
sessionId: string;
projectName: string;
startedAt: string;
}
export interface TrafficFilter {
project?: string | undefined;
session?: string | undefined;
}
type Listener = (event: TrafficEvent) => void;
const DEFAULT_MAX_BUFFER = 5000;
export class TrafficCapture {
private listeners = new Set<Listener>();
private buffer: TrafficEvent[] = [];
private readonly maxBuffer: number;
private activeSessions = new Map<string, ActiveSession>();
constructor(maxBuffer = DEFAULT_MAX_BUFFER) {
this.maxBuffer = maxBuffer;
}
emit(event: TrafficEvent): void {
// Track active sessions
if (event.eventType === 'session_created') {
this.activeSessions.set(event.sessionId, {
sessionId: event.sessionId,
projectName: event.projectName,
startedAt: event.timestamp,
});
} else if (event.eventType === 'session_closed') {
this.activeSessions.delete(event.sessionId);
}
// Ring buffer
this.buffer.push(event);
if (this.buffer.length > this.maxBuffer) {
this.buffer.splice(0, this.buffer.length - this.maxBuffer);
}
// Notify subscribers
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// Don't let a bad listener break the pipeline
}
}
}
/** Subscribe to live events. Returns unsubscribe function. */
subscribe(cb: Listener): () => void {
this.listeners.add(cb);
return () => {
this.listeners.delete(cb);
};
}
/** Get buffered events, optionally filtered. */
getBuffer(filter?: TrafficFilter): TrafficEvent[] {
let events = this.buffer;
if (filter?.project) {
events = events.filter((e) => e.projectName === filter.project);
}
if (filter?.session) {
events = events.filter((e) => e.sessionId === filter.session);
}
return events;
}
/** Get all currently active sessions. */
getActiveSessions(): ActiveSession[] {
return [...this.activeSessions.values()];
}
/** Number of subscribers (for health/debug). */
get subscriberCount(): number {
return this.listeners.size;
}
/** Total events in buffer. */
get bufferSize(): number {
return this.buffer.length;
}
}

View File

@@ -3,10 +3,11 @@ import type { LlmProcessor } from './llm/processor.js';
import { ResponsePaginator } from './llm/pagination.js';
import type { McpdClient } from './http/mcpd-client.js';
import { SessionGate } from './gate/session-gate.js';
import { TagMatcher, extractKeywordsFromToolCall } from './gate/tag-matcher.js';
import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription } from './gate/tag-matcher.js';
import type { PromptIndexEntry, TagMatchResult } from './gate/tag-matcher.js';
import { LlmPromptSelector } from './gate/llm-selector.js';
import type { ProviderRegistry } from './providers/registry.js';
import { LinkResolver } from './services/link-resolver.js';
export interface RouteContext {
sessionId?: string;
@@ -47,8 +48,13 @@ export class McpRouter {
private cachedPromptIndex: PromptIndexEntry[] | null = null;
private promptIndexFetchedAt = 0;
private readonly PROMPT_INDEX_TTL_MS = 60_000;
private linkResolver: LinkResolver | null = null;
private systemPromptCache = new Map<string, { content: string; fetchedAt: number }>();
private readonly SYSTEM_PROMPT_TTL_MS = 300_000; // 5 minutes
private pendingNotifications = new Map<string, JsonRpcNotification[]>();
/** Optional callback for traffic inspection — called after each upstream call completes. */
onUpstreamCall: ((info: { upstream: string; method?: string; request: unknown; response: unknown; durationMs: number }) => void) | null = null;
setPaginator(paginator: ResponsePaginator): void {
this.paginator = paginator;
@@ -73,6 +79,7 @@ export class McpRouter {
setPromptConfig(mcpdClient: McpdClient, projectName: string): void {
this.mcpdClient = mcpdClient;
this.projectName = projectName;
this.linkResolver = new LinkResolver(mcpdClient);
}
addUpstream(connection: UpstreamConnection): void {
@@ -277,6 +284,14 @@ export class McpRouter {
},
};
if (this.onUpstreamCall) {
const start = performance.now();
const response = await upstream.send(upstreamRequest);
const durationMs = Math.round(performance.now() - start);
this.onUpstreamCall({ upstream: serverName, method: request.method, request: upstreamRequest, response, durationMs });
return response;
}
return upstream.send(upstreamRequest);
}
@@ -303,10 +318,10 @@ export class McpRouter {
protocolVersion: '2024-11-05',
serverInfo: {
name: 'mcpctl-proxy',
version: '0.1.0',
version: '0.0.1',
},
capabilities: {
tools: {},
tools: { listChanged: true },
resources: {},
prompts: {},
},
@@ -455,16 +470,48 @@ export class McpRouter {
return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
case 'prompts/list': {
const prompts = await this.discoverPrompts();
const upstreamPrompts = await this.discoverPrompts();
// Include mcpctl-managed prompts from mcpd alongside upstream prompts
const managedIndex = await this.fetchPromptIndex();
const managedPrompts = managedIndex.map((p) => ({
name: `mcpctl/${p.name}`,
description: p.summary ?? `Priority ${p.priority} prompt`,
}));
return {
jsonrpc: '2.0',
id: request.id,
result: { prompts },
result: { prompts: [...upstreamPrompts, ...managedPrompts] },
};
}
case 'prompts/get':
case 'prompts/get': {
const promptName = (request.params as Record<string, unknown> | undefined)?.name as string | undefined;
if (promptName?.startsWith('mcpctl/')) {
const shortName = promptName.slice('mcpctl/'.length);
const managedIndex = await this.fetchPromptIndex();
const entry = managedIndex.find((p) => p.name === shortName);
if (!entry) {
return { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: `Unknown name: ${promptName}` } };
}
return {
jsonrpc: '2.0',
id: request.id,
result: {
prompt: {
name: promptName,
description: entry.summary ?? `Priority ${entry.priority} prompt`,
},
messages: [
{
role: 'user',
content: { type: 'text', text: entry.content || '(empty)' },
},
],
},
};
}
return this.routeNamespacedCall(request, 'name', this.promptToServer);
}
// Handle MCP notifications (no response expected, but return empty result if called as request)
case 'notifications/initialized':
@@ -634,6 +681,24 @@ export class McpRouter {
// ── Gate tool definitions ──
private getBeginSessionTool(): { name: string; description: string; inputSchema: unknown } {
// LLM available → description mode (natural language, LLM selects prompts)
// No LLM → keywords mode (deterministic tag matching)
if (this.llmSelector) {
return {
name: 'begin_session',
description: 'Start your session by describing what you want to accomplish. You will receive relevant project context, policies, and guidelines. This is required before using other tools.',
inputSchema: {
type: 'object',
properties: {
description: {
type: 'string',
description: "Describe what you're trying to do in a sentence or two (e.g. \"I want to pair a new Zigbee device with the hub\")",
},
},
required: ['description'],
},
};
}
return {
name: 'begin_session',
description: 'Start your session by providing keywords that describe your current task. You will receive relevant project context, policies, and guidelines. This is required before using other tools.',
@@ -680,10 +745,16 @@ export class McpRouter {
const params = request.params as Record<string, unknown> | undefined;
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
const tags = args['tags'] as string[] | undefined;
const rawTags = args['tags'] as string[] | undefined;
const description = args['description'] as string | undefined;
if (!tags || !Array.isArray(tags) || tags.length === 0) {
return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Missing or empty tags array' } };
let tags: string[];
if (rawTags && Array.isArray(rawTags) && rawTags.length > 0) {
tags = rawTags;
} else if (description && description.trim().length > 0) {
tags = tokenizeDescription(description);
} else {
return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Provide tags or description' } };
}
const sessionId = context?.sessionId;
@@ -739,6 +810,7 @@ export class McpRouter {
// Ungate the session
if (sessionId) {
this.sessionGate.ungate(sessionId, tags, matchResult);
this.queueNotification(sessionId, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
}
// Build response
@@ -778,11 +850,38 @@ export class McpRouter {
);
responseParts.push(encouragement);
// Append tool inventory (names only — full descriptions available via tools/list)
try {
const tools = await this.discoverTools();
if (tools.length > 0) {
responseParts.push('\nAvailable MCP server tools:');
for (const t of tools) {
responseParts.push(` ${t.name}`);
}
}
} catch {
// Tool discovery is optional
}
// Retry instruction (from system prompt)
const retryInstruction = await this.getSystemPrompt(
'gate-session-active',
"The session is now active with full tool access. Proceed with the user's original request using the tools listed above.",
);
responseParts.push(`\n${retryInstruction}`);
// Safety cap to prevent token overflow (prompts first = most important, tool inventory last = least)
const MAX_RESPONSE_CHARS = 24_000;
let text = responseParts.join('\n');
if (text.length > MAX_RESPONSE_CHARS) {
text = text.slice(0, MAX_RESPONSE_CHARS) + '\n\n[Response truncated. Use read_prompts to retrieve full content.]';
}
return {
jsonrpc: '2.0',
id: request.id,
result: {
content: [{ type: 'text', text: responseParts.join('\n') }],
content: [{ type: 'text', text }],
},
};
} catch (err) {
@@ -886,6 +985,7 @@ export class McpRouter {
// Ungate the session
this.sessionGate.ungate(sessionId, tags, matchResult);
this.queueNotification(sessionId, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
// Build briefing from matched content
const briefingParts: string[] = [];
@@ -909,6 +1009,20 @@ export class McpRouter {
briefingParts.push('');
}
// Append tool inventory (names only — full descriptions available via tools/list)
try {
const tools = await this.discoverTools();
if (tools.length > 0) {
briefingParts.push('Available MCP server tools:');
for (const t of tools) {
briefingParts.push(` ${t.name}`);
}
briefingParts.push('');
}
} catch {
// Tool discovery is optional
}
// Now route the actual tool call
const response = await this.routeNamespacedCall(request, 'name', this.toolToServer);
const paginatedResponse = await this.maybePaginate(toolName, response);
@@ -928,6 +1042,7 @@ export class McpRouter {
} catch {
// If prompt retrieval fails, just ungate and route normally
this.sessionGate.ungate(sessionId, tags, { fullContent: [], indexOnly: [], remaining: [] });
this.queueNotification(sessionId, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
return this.routeNamespacedCall(request, 'name', this.toolToServer);
}
}
@@ -951,17 +1066,35 @@ export class McpRouter {
summary: string | null;
chapters: string[] | null;
content?: string;
linkTarget?: string | null;
}>>(
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible`,
);
this.cachedPromptIndex = index.map((p) => ({
name: p.name,
priority: p.priority,
summary: p.summary,
chapters: p.chapters,
content: p.content ?? '',
}));
// Resolve linked prompts: fetch fresh content from linked MCP resources
const entries: PromptIndexEntry[] = [];
for (const p of index) {
let content = p.content ?? '';
if (p.linkTarget && this.linkResolver) {
try {
const resolution = await this.linkResolver.resolve(p.linkTarget);
if (resolution.status === 'alive' && resolution.content) {
content = resolution.content;
}
} catch {
// Keep static content as fallback
}
}
entries.push({
name: p.name,
priority: p.priority,
summary: p.summary,
chapters: p.chapters,
content,
});
}
this.cachedPromptIndex = entries;
this.promptIndexFetchedAt = now;
return this.cachedPromptIndex;
}
@@ -981,6 +1114,19 @@ export class McpRouter {
);
parts.push(`\n${gateInstructions}`);
// Append tool inventory (names only — descriptions come from tools/list after ungating)
try {
const tools = await this.discoverTools();
if (tools.length > 0) {
parts.push('\nAvailable MCP server tools (accessible after begin_session):');
for (const t of tools) {
parts.push(` ${t.name}`);
}
}
} catch {
// Tool discovery is optional — don't fail initialization
}
// Append compact prompt index so the LLM knows what's available
try {
const promptIndex = await this.fetchPromptIndex();
@@ -1036,10 +1182,27 @@ export class McpRouter {
}
}
// ── Notification queue ──
private queueNotification(sessionId: string | undefined, notification: JsonRpcNotification): void {
if (!sessionId) return;
const queue = this.pendingNotifications.get(sessionId) ?? [];
queue.push(notification);
this.pendingNotifications.set(sessionId, queue);
}
/** Consume and return any pending notifications for a session (e.g., tools/list_changed after ungating). */
consumeNotifications(sessionId: string): JsonRpcNotification[] {
const notifications = this.pendingNotifications.get(sessionId) ?? [];
this.pendingNotifications.delete(sessionId);
return notifications;
}
// ── Session cleanup ──
cleanupSession(sessionId: string): void {
this.sessionGate.removeSession(sessionId);
this.pendingNotifications.delete(sessionId);
}
getUpstreamNames(): string[] {

View File

@@ -32,7 +32,7 @@ export class HttpUpstream implements UpstreamConnection {
port: parsed.port,
path: parsed.pathname,
method: 'POST',
timeout: 30000,
timeout: 120_000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),

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