import { describe, it, expect, vi, beforeEach } from 'vitest'; import { McpRouter } from '../src/router.js'; import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js'; import type { McpdClient } from '../src/http/mcpd-client.js'; import { ProviderRegistry } from '../src/providers/registry.js'; import type { LlmProvider, CompletionResult } from '../src/providers/types.js'; function mockUpstream( name: string, opts: { tools?: Array<{ name: string; description?: string }> } = {}, ): UpstreamConnection { return { name, isAlive: vi.fn(() => true), close: vi.fn(async () => {}), onNotification: vi.fn(), send: vi.fn(async (req: JsonRpcRequest): Promise => { if (req.method === 'tools/list') { return { jsonrpc: '2.0', id: req.id, result: { tools: opts.tools ?? [] } }; } if (req.method === 'tools/call') { return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: `Called ${(req.params as Record)?.name}` }] }, }; } if (req.method === 'resources/list') { return { jsonrpc: '2.0', id: req.id, result: { resources: [] } }; } if (req.method === 'prompts/list') { return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } }; } return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } }; }), } as UpstreamConnection; } function mockMcpdClient(prompts: Array<{ name: string; priority: number; summary: string | null; chapters: string[] | null; content: string; type?: string }> = []): McpdClient { return { get: vi.fn(async (path: string) => { if (path.includes('/prompts/visible')) { return prompts.map((p) => ({ ...p, type: p.type ?? 'prompt' })); } if (path.includes('/prompt-index')) { return prompts.map((p) => ({ name: p.name, priority: p.priority, summary: p.summary, chapters: p.chapters, })); } return []; }), post: vi.fn(async () => ({})), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), forward: vi.fn(async () => ({ status: 200, body: {} })), withHeaders: vi.fn(function (this: McpdClient) { return this; }), } as unknown as McpdClient; } const samplePrompts = [ { name: 'common-mistakes', priority: 10, summary: 'Critical safety rules everyone must follow', chapters: null, content: 'NEVER do X. ALWAYS do Y.' }, { name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices with the hub', chapters: ['Setup', 'Troubleshooting'], content: 'Step 1: Put device in pairing mode...' }, { name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration guide', chapters: ['Broker Setup', 'Authentication'], content: 'Configure the MQTT broker at...' }, { name: 'security-policy', priority: 8, summary: 'Security policies for production deployments', chapters: ['Network', 'Auth'], content: 'All connections must use TLS...' }, ]; function setupGatedRouter( opts: { gated?: boolean; prompts?: typeof samplePrompts; withLlm?: boolean; llmResponse?: string; byteBudget?: number; } = {}, ): { router: McpRouter; mcpdClient: McpdClient } { const router = new McpRouter(); const prompts = opts.prompts ?? samplePrompts; const mcpdClient = mockMcpdClient(prompts); router.setPromptConfig(mcpdClient, 'test-project'); let providerRegistry: ProviderRegistry | null = null; if (opts.withLlm) { providerRegistry = new ProviderRegistry(); const mockProvider: LlmProvider = { name: 'mock-heavy', complete: vi.fn().mockResolvedValue({ content: opts.llmResponse ?? '{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }', toolCalls: [], usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, finishReason: 'stop', } satisfies CompletionResult), listModels: vi.fn().mockResolvedValue([]), isAvailable: vi.fn().mockResolvedValue(true), }; providerRegistry.register(mockProvider); providerRegistry.assignTier(mockProvider.name, 'heavy'); } router.setGateConfig({ gated: opts.gated !== false, providerRegistry, byteBudget: opts.byteBudget, }); return { router, mcpdClient }; } describe('McpRouter gating', () => { describe('initialize with gating', () => { it('creates gated session on initialize', async () => { const { router } = setupGatedRouter(); const res = await router.route( { jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }, ); expect(res.result).toBeDefined(); // The session should be gated now const toolsRes = await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/list' }, { sessionId: 's1' }, ); const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; expect(tools).toHaveLength(1); expect(tools[0]!.name).toBe('begin_session'); }); it('creates ungated session when project is not gated', async () => { const { router } = setupGatedRouter({ gated: false }); router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] })); await router.route( { jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }, ); const toolsRes = await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/list' }, { sessionId: 's1' }, ); const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; const names = tools.map((t) => t.name); expect(names).toContain('ha/get_entities'); expect(names).toContain('read_prompts'); expect(names).not.toContain('begin_session'); }); }); describe('tools/list gating', () => { it('shows only begin_session when session is gated', 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/list' }, { sessionId: 's1' }, ); const tools = (res.result as { tools: Array<{ name: string }> }).tools; expect(tools).toHaveLength(1); expect(tools[0]!.name).toBe('begin_session'); }); it('shows all tools plus read_prompts after ungating', async () => { const { router } = setupGatedRouter(); router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] })); await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); // Ungate via begin_session await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, { sessionId: 's1' }, ); const toolsRes = await router.route( { jsonrpc: '2.0', id: 3, method: 'tools/list' }, { sessionId: 's1' }, ); const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; const names = tools.map((t) => t.name); expect(names).toContain('ha/get_entities'); expect(names).toContain('propose_prompt'); expect(names).toContain('read_prompts'); expect(names).not.toContain('begin_session'); }); }); describe('begin_session', () => { it('returns matched prompts with keyword matching', 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', 'pairing'] } } }, { sessionId: 's1' }, ); expect(res.error).toBeUndefined(); const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); // Should include priority 10 prompt expect(text).toContain('common-mistakes'); expect(text).toContain('NEVER do X'); // Should include zigbee-pairing (matches both tags) expect(text).toContain('zigbee-pairing'); expect(text).toContain('pairing mode'); // Should include encouragement expect(text).toContain('read_prompts'); }); it('includes priority 10 prompts even without matching 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: { tags: ['unrelated-keyword'] } } }, { sessionId: 's1' }, ); const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); expect(text).toContain('common-mistakes'); expect(text).toContain('NEVER do X'); }); it('uses LLM selection when provider is available', async () => { const { router } = setupGatedRouter({ withLlm: true, llmResponse: '{ "selectedNames": ["zigbee-pairing", "security-policy"], "reasoning": "Zigbee pairing needs security awareness" }', }); 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('Zigbee pairing needs security awareness'); expect(text).toContain('zigbee-pairing'); expect(text).toContain('security-policy'); expect(text).toContain('common-mistakes'); // priority 10 always included }); it('rejects empty 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: { tags: [] } } }, { sessionId: 's1' }, ); expect(res.error).toBeDefined(); expect(res.error!.code).toBe(-32602); }); it('returns message when session is already ungated', async () => { const { router } = setupGatedRouter(); await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); // First call ungates await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, { sessionId: 's1' }, ); // Second call tells user to use read_prompts const res = await router.route( { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'] } } }, { sessionId: 's1' }, ); const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); expect(text).toContain('already started'); expect(text).toContain('read_prompts'); }); it('lists remaining prompts for awareness', 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); // Non-matching prompts should be listed as "other available prompts" // security-policy doesn't match 'zigbee' in keyword mode expect(text).toContain('security-policy'); }); }); describe('read_prompts', () => { it('returns prompts matching keywords', async () => { const { router } = setupGatedRouter({ gated: false }); 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: 'read_prompts', arguments: { tags: ['mqtt', 'broker'] } } }, { sessionId: 's1' }, ); expect(res.error).toBeUndefined(); const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); expect(text).toContain('mqtt-config'); expect(text).toContain('Configure the MQTT broker'); }); it('filters out already-sent prompts', async () => { // 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 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 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' }, ); const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); expect(text).toContain('mqtt-config'); // common-mistakes was already sent, should not appear again expect(text).not.toContain('NEVER do X'); }); it('returns message when no new prompts match', async () => { const { router } = setupGatedRouter({ prompts: [] }); 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: 'read_prompts', arguments: { tags: ['nonexistent'] } } }, { sessionId: 's1' }, ); const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); expect(text).toContain('No new matching prompts'); }); it('rejects empty tags', async () => { const { router } = setupGatedRouter({ gated: false }); 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: 'read_prompts', arguments: { tags: [] } } }, { sessionId: 's1' }, ); expect(res.error).toBeDefined(); expect(res.error!.code).toBe(-32602); }); }); describe('gated intercept', () => { it('auto-ungates when gated session calls a real tool', 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' }); // Call a real tool while gated — should intercept, extract keywords, and route const res = await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } }, { sessionId: 's1' }, ); // Response should include the tool result expect(res.error).toBeUndefined(); const result = res.result as { content: Array<{ type: string; text: string }> }; // Should have briefing prepended expect(result.content.length).toBeGreaterThanOrEqual(1); // Session should now be ungated const toolsRes = await router.route( { jsonrpc: '2.0', id: 3, method: 'tools/list' }, { sessionId: 's1' }, ); const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; expect(tools.map((t) => t.name)).toContain('ha/get_entities'); }); it('includes project context in intercepted response', 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' }); const res = await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } }, { sessionId: 's1' }, ); const result = res.result as { content: Array<{ type: string; text: string }> }; // First content block should be the briefing (priority 10 at minimum) const briefing = result.content[0]!.text; expect(briefing).toContain('common-mistakes'); expect(briefing).toContain('NEVER do X'); }); }); describe('initialize instructions for gated projects', () => { it('includes gate message and prompt index in instructions', async () => { const { router } = setupGatedRouter(); const res = await router.route( { jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }, ); const result = res.result as { instructions?: string }; expect(result.instructions).toBeDefined(); expect(result.instructions).toContain('begin_session'); expect(result.instructions).toContain('gated session'); // Should list available prompts expect(result.instructions).toContain('common-mistakes'); expect(result.instructions).toContain('zigbee-pairing'); }); it('does not include gate message for non-gated projects', async () => { const { router } = setupGatedRouter({ gated: false }); router.setInstructions('Base project instructions'); const res = await router.route( { jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }, ); const result = res.result as { instructions?: string }; expect(result.instructions).toBe('Base project instructions'); expect(result.instructions).not.toContain('gated session'); }); it('preserves base instructions and appends gate message', async () => { const { router } = setupGatedRouter(); router.setInstructions('You are a helpful assistant.'); 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('You are a helpful assistant.'); expect(result.instructions).toContain('begin_session'); }); it('sorts prompt index by priority descending', async () => { const { router } = setupGatedRouter(); const res = await router.route( { jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }, ); const result = res.result as { instructions: string }; const lines = result.instructions.split('\n'); // Find the prompt index lines const promptLines = lines.filter((l) => l.startsWith('- ') && l.includes('priority')); // priority 10 should come first expect(promptLines[0]).toContain('common-mistakes'); expect(promptLines[0]).toContain('priority 10'); }); }); describe('session cleanup', () => { it('cleanupSession removes gate state', async () => { const { router } = setupGatedRouter(); await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); // Session is gated 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'); // Cleanup router.cleanupSession('s1'); // After cleanup, session is treated as unknown (ungated) toolsRes = await router.route( { jsonrpc: '2.0', id: 3, method: 'tools/list' }, { sessionId: 's1' }, ); const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; expect(tools.map((t) => t.name)).not.toContain('begin_session'); }); }); 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 }); await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); // First read_prompts call fetches from mcpd await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } }, { sessionId: 's1' }, ); // Second call should use cache await router.route( { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['zigbee'] } } }, { sessionId: 's1' }, ); // mcpdClient.get should have been called only once for prompts/visible const getCalls = vi.mocked(mcpdClient.get).mock.calls.filter((c) => (c[0] as string).includes('/prompts/visible')); 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'); }); }); });