feat: gated project experience & prompt intelligence
Implements the full gated session flow and prompt intelligence system: - Prisma schema: add gated, priority, summary, chapters, linkTarget fields - Session gate: state machine (gated → begin_session → ungated) with LLM-powered tool selection based on prompt index - Tag matcher: intelligent prompt-to-tool matching with project/server/action tags - LLM selector: tiered provider selection (fast for gating, heavy for complex tasks) - Link resolver: cross-project MCP resource references (project/server:uri format) - Prompt summary service: LLM-generated summaries and chapter extraction - System project bootstrap: ensures default project exists on startup - Structural link health checks: enrichWithLinkStatus on prompt GET endpoints - CLI: create prompt --priority/--link, create project --gated/--no-gated, describe project shows prompts section, get prompts shows PRI/LINK/STATUS - Apply/edit: priority, linkTarget, gated fields supported - Shell completions: fish updated with new flags - 1,253 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
241
src/mcplocal/tests/link-resolver.test.ts
Normal file
241
src/mcplocal/tests/link-resolver.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { LinkResolver } from '../src/services/link-resolver.js';
|
||||
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||
|
||||
function mockClient(): McpdClient {
|
||||
return {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
withHeaders: vi.fn(),
|
||||
} as unknown as McpdClient;
|
||||
}
|
||||
|
||||
describe('LinkResolver', () => {
|
||||
let client: McpdClient;
|
||||
let resolver: LinkResolver;
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
resolver = new LinkResolver(client, 1000); // 1s TTL for tests
|
||||
});
|
||||
|
||||
// ── parseLink ──
|
||||
|
||||
describe('parseLink', () => {
|
||||
it('parses valid link target', () => {
|
||||
const result = resolver.parseLink('my-project/docmost-mcp:docmost://pages/abc');
|
||||
expect(result).toEqual({
|
||||
project: 'my-project',
|
||||
server: 'docmost-mcp',
|
||||
uri: 'docmost://pages/abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses link with complex URI', () => {
|
||||
const result = resolver.parseLink('proj/srv:file:///path/to/resource');
|
||||
expect(result).toEqual({
|
||||
project: 'proj',
|
||||
server: 'srv',
|
||||
uri: 'file:///path/to/resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on missing project separator', () => {
|
||||
expect(() => resolver.parseLink('noslash')).toThrow('missing project');
|
||||
});
|
||||
|
||||
it('throws on missing server:uri separator', () => {
|
||||
expect(() => resolver.parseLink('proj/nocolon')).toThrow('missing server:uri');
|
||||
});
|
||||
|
||||
it('throws on empty uri', () => {
|
||||
expect(() => resolver.parseLink('proj/srv:')).toThrow('empty uri');
|
||||
});
|
||||
|
||||
it('throws when project is empty', () => {
|
||||
expect(() => resolver.parseLink('/srv:uri')).toThrow('missing project');
|
||||
});
|
||||
|
||||
it('throws when server is empty', () => {
|
||||
expect(() => resolver.parseLink('proj/:uri')).toThrow('missing server:uri');
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolve ──
|
||||
|
||||
describe('resolve', () => {
|
||||
it('fetches resource content successfully', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'srv-id-1', name: 'docmost-mcp' },
|
||||
]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'Hello from docmost', uri: 'docmost://pages/abc' }] },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('my-project/docmost-mcp:docmost://pages/abc');
|
||||
|
||||
expect(result).toEqual({ content: 'Hello from docmost', status: 'alive' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my-project/servers');
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', {
|
||||
serverId: 'srv-id-1',
|
||||
method: 'resources/read',
|
||||
params: { uri: 'docmost://pages/abc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns dead status when server not found in project', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'srv-other', name: 'other-server' },
|
||||
]);
|
||||
|
||||
const result = await resolver.resolve('proj/missing-srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.content).toBeNull();
|
||||
expect(result.error).toContain("Server 'missing-srv' not found");
|
||||
});
|
||||
|
||||
it('returns dead status when MCP proxy returns error', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
error: { code: -32601, message: 'Method not found' },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.error).toContain('Method not found');
|
||||
});
|
||||
|
||||
it('returns dead status when no content returned', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [] },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.error).toContain('No content returned');
|
||||
});
|
||||
|
||||
it('returns dead status on network error', async () => {
|
||||
vi.mocked(client.get).mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('concatenates multiple content entries', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: {
|
||||
contents: [
|
||||
{ text: 'Part 1', uri: 'uri1' },
|
||||
{ text: 'Part 2', uri: 'uri2' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.content).toBe('Part 1\nPart 2');
|
||||
expect(result.status).toBe('alive');
|
||||
});
|
||||
|
||||
it('logs dead link to console.error', async () => {
|
||||
vi.mocked(client.get).mockRejectedValue(new Error('fail'));
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[link-resolver] Dead link'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── caching ──
|
||||
|
||||
describe('caching', () => {
|
||||
it('returns cached result on second call', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'cached content' }] },
|
||||
});
|
||||
|
||||
const first = await resolver.resolve('proj/srv:some://uri');
|
||||
const second = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(first).toEqual(second);
|
||||
// Only one HTTP call — second was cached
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches after cache expires', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'content' }] },
|
||||
});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
// Advance time past TTL
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('clearCache removes all entries', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'content' }] },
|
||||
});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
resolver.clearCache();
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkHealth ──
|
||||
|
||||
describe('checkHealth', () => {
|
||||
it('returns cached status if available', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'content' }] },
|
||||
});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
const health = await resolver.checkHealth('proj/srv:some://uri');
|
||||
|
||||
expect(health).toBe('alive');
|
||||
});
|
||||
|
||||
it('returns unknown if not cached', async () => {
|
||||
const health = await resolver.checkHealth('proj/srv:some://uri');
|
||||
expect(health).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns dead from cached dead link', async () => {
|
||||
vi.mocked(client.get).mockRejectedValue(new Error('fail'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
const health = await resolver.checkHealth('proj/srv:some://uri');
|
||||
|
||||
expect(health).toBe('dead');
|
||||
});
|
||||
});
|
||||
});
|
||||
166
src/mcplocal/tests/llm-selector.test.ts
Normal file
166
src/mcplocal/tests/llm-selector.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { LlmPromptSelector, type PromptIndexForLlm } from '../src/gate/llm-selector.js';
|
||||
import { ProviderRegistry } from '../src/providers/registry.js';
|
||||
import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js';
|
||||
|
||||
function makeMockProvider(responseContent: string): LlmProvider {
|
||||
return {
|
||||
name: 'mock-heavy',
|
||||
complete: vi.fn().mockResolvedValue({
|
||||
content: responseContent,
|
||||
toolCalls: [],
|
||||
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
|
||||
finishReason: 'stop',
|
||||
} satisfies CompletionResult),
|
||||
listModels: vi.fn().mockResolvedValue(['mock-model']),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(provider: LlmProvider): ProviderRegistry {
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(provider);
|
||||
registry.assignTier(provider.name, 'heavy');
|
||||
return registry;
|
||||
}
|
||||
|
||||
const sampleIndex: PromptIndexForLlm[] = [
|
||||
{ name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices', chapters: ['Setup', 'Troubleshooting'] },
|
||||
{ name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration', chapters: null },
|
||||
{ name: 'common-mistakes', priority: 10, summary: 'Critical safety rules', chapters: null },
|
||||
];
|
||||
|
||||
describe('LlmPromptSelector', () => {
|
||||
it('sends tags and index to heavy LLM and parses response', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'```json\n{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }\n```',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['zigbee', 'pairing'], sampleIndex);
|
||||
|
||||
expect(result.selectedNames).toContain('zigbee-pairing');
|
||||
expect(result.selectedNames).toContain('common-mistakes'); // Priority 10 always included
|
||||
expect(result.reasoning).toBe('User is working with zigbee');
|
||||
});
|
||||
|
||||
it('always includes priority 10 prompts even if LLM omits them', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": ["mqtt-config"], "reasoning": "MQTT related" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['mqtt'], sampleIndex);
|
||||
|
||||
expect(result.selectedNames).toContain('mqtt-config');
|
||||
expect(result.selectedNames).toContain('common-mistakes');
|
||||
});
|
||||
|
||||
it('does not duplicate priority 10 if LLM already selected them', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": ["common-mistakes", "mqtt-config"], "reasoning": "Both needed" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['mqtt'], sampleIndex);
|
||||
|
||||
const count = result.selectedNames.filter((n) => n === 'common-mistakes').length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('passes system and user messages to provider.complete', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": [], "reasoning": "none" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await selector.selectPrompts(['test'], sampleIndex);
|
||||
|
||||
expect(provider.complete).toHaveBeenCalledOnce();
|
||||
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
|
||||
expect(call.messages).toHaveLength(2);
|
||||
expect(call.messages[0]!.role).toBe('system');
|
||||
expect(call.messages[1]!.role).toBe('user');
|
||||
expect(call.messages[1]!.content).toContain('test');
|
||||
expect(call.temperature).toBe(0);
|
||||
});
|
||||
|
||||
it('passes model override to complete options', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": [], "reasoning": "" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry, 'gemini-pro');
|
||||
|
||||
await selector.selectPrompts(['test'], sampleIndex);
|
||||
|
||||
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
|
||||
expect(call.model).toBe('gemini-pro');
|
||||
});
|
||||
|
||||
it('throws when no heavy provider is available', async () => {
|
||||
const registry = new ProviderRegistry(); // Empty registry
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow(
|
||||
'No heavy LLM provider available',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when LLM response has no valid JSON', async () => {
|
||||
const provider = makeMockProvider('I cannot help with that request.');
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow(
|
||||
'LLM response did not contain valid selection JSON',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles response with empty selectedNames', async () => {
|
||||
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "nothing matched" }');
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
// Empty selectedNames, but priority 10 should still be included
|
||||
const result = await selector.selectPrompts(['test'], sampleIndex);
|
||||
expect(result.selectedNames).toEqual(['common-mistakes']);
|
||||
expect(result.reasoning).toBe('nothing matched');
|
||||
});
|
||||
|
||||
it('handles response with reasoning missing', async () => {
|
||||
const provider = makeMockProvider('{ "selectedNames": ["mqtt-config"] }');
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['test'], sampleIndex);
|
||||
expect(result.reasoning).toBe('');
|
||||
expect(result.selectedNames).toContain('mqtt-config');
|
||||
});
|
||||
|
||||
it('includes prompt details in the user prompt', async () => {
|
||||
const indexWithNull: PromptIndexForLlm[] = [
|
||||
...sampleIndex,
|
||||
{ name: 'no-desc', priority: 3, summary: null, chapters: null },
|
||||
];
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": [], "reasoning": "" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await selector.selectPrompts(['zigbee'], indexWithNull);
|
||||
|
||||
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
|
||||
const userMsg = call.messages[1]!.content;
|
||||
expect(userMsg).toContain('zigbee-pairing');
|
||||
expect(userMsg).toContain('priority: 7');
|
||||
expect(userMsg).toContain('How to pair Zigbee devices');
|
||||
expect(userMsg).toContain('Setup, Troubleshooting');
|
||||
expect(userMsg).toContain('No summary'); // For prompts with null summary
|
||||
});
|
||||
});
|
||||
520
src/mcplocal/tests/router-gate.test.ts
Normal file
520
src/mcplocal/tests/router-gate.test.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -165,16 +165,13 @@ describe('McpRouter - Prompt Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should read mcpctl resource content', async () => {
|
||||
it('should read mcpctl resource content live from mcpd', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
vi.mocked(mcpdClient.get).mockResolvedValue([
|
||||
{ name: 'my-prompt', content: 'The content here', type: 'prompt' },
|
||||
]);
|
||||
|
||||
// First list to populate cache
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||
|
||||
// Then read
|
||||
// Read directly — no need to list first
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
@@ -187,8 +184,55 @@ describe('McpRouter - Prompt Integration', () => {
|
||||
expect(contents[0]!.text).toBe('The content here');
|
||||
});
|
||||
|
||||
it('should return fresh content after prompt update', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
|
||||
// First call returns old content
|
||||
vi.mocked(mcpdClient.get).mockResolvedValueOnce([
|
||||
{ name: 'my-prompt', content: 'Old content', type: 'prompt' },
|
||||
]);
|
||||
await router.route({
|
||||
jsonrpc: '2.0', id: 1, method: 'resources/read',
|
||||
params: { uri: 'mcpctl://prompts/my-prompt' },
|
||||
});
|
||||
|
||||
// Second call returns updated content
|
||||
vi.mocked(mcpdClient.get).mockResolvedValueOnce([
|
||||
{ name: 'my-prompt', content: 'Updated content', type: 'prompt' },
|
||||
]);
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0', id: 2, method: 'resources/read',
|
||||
params: { uri: 'mcpctl://prompts/my-prompt' },
|
||||
});
|
||||
|
||||
const contents = (response.result as { contents: Array<{ text: string }> }).contents;
|
||||
expect(contents[0]!.text).toBe('Updated content');
|
||||
});
|
||||
|
||||
it('should fall back to cache when mcpd is unreachable on read', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
|
||||
// Populate cache via list
|
||||
vi.mocked(mcpdClient.get).mockResolvedValueOnce([
|
||||
{ name: 'cached-prompt', content: 'Cached content', type: 'prompt' },
|
||||
]);
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||
|
||||
// mcpd goes down for read
|
||||
vi.mocked(mcpdClient.get).mockRejectedValueOnce(new Error('Connection refused'));
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0', id: 2, method: 'resources/read',
|
||||
params: { uri: 'mcpctl://prompts/cached-prompt' },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
const contents = (response.result as { contents: Array<{ text: string }> }).contents;
|
||||
expect(contents[0]!.text).toBe('Cached content');
|
||||
});
|
||||
|
||||
it('should return error for unknown mcpctl resource', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
vi.mocked(mcpdClient.get).mockResolvedValue([]);
|
||||
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
|
||||
155
src/mcplocal/tests/session-gate.test.ts
Normal file
155
src/mcplocal/tests/session-gate.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionGate } from '../src/gate/session-gate.js';
|
||||
import type { TagMatchResult, PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
|
||||
function makeMatchResult(names: string[]): TagMatchResult {
|
||||
return {
|
||||
fullContent: names.map((name) => ({
|
||||
name,
|
||||
priority: 5,
|
||||
summary: null,
|
||||
chapters: null,
|
||||
content: `Content of ${name}`,
|
||||
})),
|
||||
indexOnly: [],
|
||||
remaining: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionGate', () => {
|
||||
it('creates a gated session when project is gated', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
expect(gate.isGated('s1')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates an ungated session when project is not gated', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', false);
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown sessions are treated as ungated', () => {
|
||||
const gate = new SessionGate();
|
||||
expect(gate.isGated('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('getSession returns null for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
expect(gate.getSession('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('getSession returns session state', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
const state = gate.getSession('s1');
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.gated).toBe(true);
|
||||
expect(state!.tags).toEqual([]);
|
||||
expect(state!.retrievedPrompts.size).toBe(0);
|
||||
expect(state!.briefing).toBeNull();
|
||||
});
|
||||
|
||||
it('ungate marks session as ungated and records tags', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
|
||||
gate.ungate('s1', ['zigbee', 'mqtt'], makeMatchResult(['prompt-a', 'prompt-b']));
|
||||
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
const state = gate.getSession('s1');
|
||||
expect(state!.tags).toEqual(['zigbee', 'mqtt']);
|
||||
expect(state!.retrievedPrompts.has('prompt-a')).toBe(true);
|
||||
expect(state!.retrievedPrompts.has('prompt-b')).toBe(true);
|
||||
});
|
||||
|
||||
it('ungate appends tags on repeated calls', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
gate.ungate('s1', ['mqtt'], makeMatchResult(['p2']));
|
||||
|
||||
const state = gate.getSession('s1');
|
||||
expect(state!.tags).toEqual(['zigbee', 'mqtt']);
|
||||
expect(state!.retrievedPrompts.has('p1')).toBe(true);
|
||||
expect(state!.retrievedPrompts.has('p2')).toBe(true);
|
||||
});
|
||||
|
||||
it('ungate is no-op for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
// Should not throw
|
||||
gate.ungate('nonexistent', ['tag'], makeMatchResult(['p']));
|
||||
});
|
||||
|
||||
it('addRetrievedPrompts records additional prompts', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
|
||||
gate.addRetrievedPrompts('s1', ['mqtt', 'lights'], ['p2', 'p3']);
|
||||
|
||||
const state = gate.getSession('s1');
|
||||
expect(state!.tags).toEqual(['zigbee', 'mqtt', 'lights']);
|
||||
expect(state!.retrievedPrompts.has('p2')).toBe(true);
|
||||
expect(state!.retrievedPrompts.has('p3')).toBe(true);
|
||||
});
|
||||
|
||||
it('addRetrievedPrompts is no-op for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.addRetrievedPrompts('nonexistent', ['tag'], ['p']);
|
||||
});
|
||||
|
||||
it('filterAlreadySent removes already-sent prompts', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
|
||||
const prompts: PromptIndexEntry[] = [
|
||||
{ name: 'p1', priority: 5, summary: 'already sent', chapters: null, content: 'x' },
|
||||
{ name: 'p2', priority: 5, summary: 'new', chapters: null, content: 'y' },
|
||||
];
|
||||
|
||||
const filtered = gate.filterAlreadySent('s1', prompts);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]!.name).toBe('p2');
|
||||
});
|
||||
|
||||
it('filterAlreadySent returns all prompts for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
const prompts: PromptIndexEntry[] = [
|
||||
{ name: 'p1', priority: 5, summary: null, chapters: null, content: 'x' },
|
||||
];
|
||||
|
||||
const filtered = gate.filterAlreadySent('nonexistent', prompts);
|
||||
expect(filtered).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removeSession cleans up state', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
expect(gate.getSession('s1')).not.toBeNull();
|
||||
|
||||
gate.removeSession('s1');
|
||||
expect(gate.getSession('s1')).toBeNull();
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('removeSession is safe for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.removeSession('nonexistent'); // Should not throw
|
||||
});
|
||||
|
||||
it('manages multiple sessions independently', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
gate.createSession('s2', false);
|
||||
|
||||
expect(gate.isGated('s1')).toBe(true);
|
||||
expect(gate.isGated('s2')).toBe(false);
|
||||
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
expect(gate.getSession('s2')!.tags).toEqual([]); // s2 untouched
|
||||
});
|
||||
});
|
||||
165
src/mcplocal/tests/tag-matcher.test.ts
Normal file
165
src/mcplocal/tests/tag-matcher.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, type PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
|
||||
function makePrompt(overrides: Partial<PromptIndexEntry> = {}): PromptIndexEntry {
|
||||
return {
|
||||
name: 'test-prompt',
|
||||
priority: 5,
|
||||
summary: 'A test prompt for testing',
|
||||
chapters: ['Chapter One', 'Chapter Two'],
|
||||
content: 'Full content of the test prompt.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TagMatcher', () => {
|
||||
it('returns priority 10 prompts regardless of tags', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('scores by 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
|
||||
const result = matcher.match(['zigbee'], [low, high]);
|
||||
expect(result.fullContent[0]!.name).toBe('important');
|
||||
expect(result.fullContent[1]!.name).toBe('basic');
|
||||
});
|
||||
|
||||
it('matches more tags = higher score', () => {
|
||||
const matcher = new 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
|
||||
const result = matcher.match(['zigbee', 'mqtt'], [oneMatch, twoMatch]);
|
||||
expect(result.fullContent[0]!.name).toBe('two-match');
|
||||
});
|
||||
|
||||
it('performs case-insensitive matching', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const prompt = makePrompt({ name: 'test', summary: 'ZIGBEE Protocol Setup' });
|
||||
|
||||
const result = matcher.match(['zigbee'], [prompt]);
|
||||
expect(result.fullContent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches against name, summary, and chapters', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const byName = makePrompt({ name: 'zigbee-config', summary: 'unrelated', chapters: [] });
|
||||
const bySummary = makePrompt({ name: 'setup', summary: 'zigbee setup guide', chapters: [] });
|
||||
const byChapter = makePrompt({ name: 'guide', summary: 'unrelated', chapters: ['Zigbee Pairing'] });
|
||||
|
||||
const result = matcher.match(['zigbee'], [byName, bySummary, byChapter]);
|
||||
expect(result.fullContent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('respects byte budget', () => {
|
||||
const matcher = new TagMatcher(100); // Very small budget
|
||||
const small = makePrompt({ name: 'small', summary: 'zigbee', content: 'Short.' }); // ~6 bytes
|
||||
const big = makePrompt({ name: 'big', summary: 'zigbee', content: 'x'.repeat(200) }); // 200 bytes
|
||||
|
||||
const result = matcher.match(['zigbee'], [small, big]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['small']);
|
||||
expect(result.indexOnly.map((p) => p.name)).toEqual(['big']);
|
||||
});
|
||||
|
||||
it('puts non-matched prompts in remaining', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('handles empty tags — only priority 10 matched', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('handles empty prompts array', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const result = matcher.match(['zigbee'], []);
|
||||
expect(result.fullContent).toEqual([]);
|
||||
expect(result.indexOnly).toEqual([]);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('all priority 10 prompts are included even beyond budget', () => {
|
||||
const matcher = new TagMatcher(50); // Tiny budget
|
||||
const c1 = makePrompt({ name: 'c1', priority: 10, content: 'x'.repeat(40) });
|
||||
const c2 = makePrompt({ name: 'c2', priority: 10, content: 'y'.repeat(40) });
|
||||
|
||||
const result = matcher.match([], [c1, c2]);
|
||||
// Both should be in fullContent — priority 10 has Infinity score
|
||||
// First one fits budget, second overflows but still priority 10
|
||||
expect(result.fullContent.length + result.indexOnly.length).toBe(2);
|
||||
// At minimum the first one is in fullContent
|
||||
expect(result.fullContent[0]!.name).toBe('c1');
|
||||
});
|
||||
|
||||
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 result = matcher.match(['mqtt', 'zigbee', 'lights', 'pairing', 'automation'], [p1, p2, p3]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['p3', 'p1', 'p2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKeywordsFromToolCall', () => {
|
||||
it('extracts from tool name', () => {
|
||||
const keywords = extractKeywordsFromToolCall('home-assistant/get_entities', {});
|
||||
expect(keywords).toContain('home');
|
||||
expect(keywords).toContain('assistant');
|
||||
expect(keywords).toContain('get_entities');
|
||||
});
|
||||
|
||||
it('extracts from string arguments', () => {
|
||||
const keywords = extractKeywordsFromToolCall('tool', { domain: 'light', area: 'kitchen' });
|
||||
expect(keywords).toContain('light');
|
||||
expect(keywords).toContain('kitchen');
|
||||
});
|
||||
|
||||
it('ignores short words (<=2 chars)', () => {
|
||||
const keywords = extractKeywordsFromToolCall('ab', { x: 'hi' });
|
||||
expect(keywords).not.toContain('ab');
|
||||
expect(keywords).not.toContain('hi');
|
||||
});
|
||||
|
||||
it('ignores long string values (>200 chars)', () => {
|
||||
const keywords = extractKeywordsFromToolCall('tool', { data: 'x'.repeat(201) });
|
||||
// Only 'tool' from the name
|
||||
expect(keywords).toEqual(['tool']);
|
||||
});
|
||||
|
||||
it('caps at 10 keywords', () => {
|
||||
const args: Record<string, string> = {};
|
||||
for (let i = 0; i < 20; i++) args[`key${i}`] = `keyword${i}value`;
|
||||
const keywords = extractKeywordsFromToolCall('tool', args);
|
||||
expect(keywords.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('lowercases all keywords', () => {
|
||||
const keywords = extractKeywordsFromToolCall('MyTool', { name: 'MQTT' });
|
||||
expect(keywords).toContain('mytool');
|
||||
expect(keywords).toContain('mqtt');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user