521 lines
21 KiB
TypeScript
521 lines
21 KiB
TypeScript
|
|
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<JsonRpcResponse> => {
|
||
|
|
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<string, unknown>)?.name}` }] },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (req.method === 'resources/list') {
|
||
|
|
return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
|
||
|
|
}
|
||
|
|
if (req.method === 'prompts/list') {
|
||
|
|
return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } };
|
||
|
|
}
|
||
|
|
return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
|
||
|
|
}),
|
||
|
|
} as UpstreamConnection;
|
||
|
|
}
|
||
|
|
|
||
|
|
function mockMcpdClient(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;
|
||
|
|
} = {},
|
||
|
|
): { 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,
|
||
|
|
});
|
||
|
|
|
||
|
|
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 () => {
|
||
|
|
const { router } = setupGatedRouter();
|
||
|
|
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||
|
|
|
||
|
|
// begin_session sends common-mistakes (priority 10) and zigbee-pairing
|
||
|
|
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
|
||
|
|
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('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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|