diff --git a/src/server/tools/guided-query-tool.test.ts b/src/server/tools/guided-query-tool.test.ts new file mode 100644 index 0000000..05dcd08 --- /dev/null +++ b/src/server/tools/guided-query-tool.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPineconeClient } from '../client-context.js'; +import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { registerGuidedQueryTool } from './guided-query-tool.js'; +import { + createMockServer, + makeNamespaceCacheEntry, + makeSearchResult, + parseToolJson, +} from './test-helpers.js'; + +vi.mock('../client-context.js', () => ({ + getPineconeClient: vi.fn(), +})); + +vi.mock('../namespaces-cache.js', () => ({ + getNamespacesWithCache: vi.fn(), +})); + +/** Real `markSuggested` may call `getServerConfig()` during sweep (CI has no API key); isolate the handler. */ +vi.mock('../suggestion-flow.js', () => ({ + markSuggested: vi.fn(), +})); + +const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); +const mockedGetClient = vi.mocked(getPineconeClient); + +describe('guided_query tool handler', () => { + const nsEntry = makeNamespaceCacheEntry('papers', { + document_number: 'string', + title: 'string', + url: 'string', + author: 'string', + chunk_text: 'string', + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockedGetNamespaces.mockResolvedValue({ + data: [nsEntry], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue([makeSearchResult()]), + count: vi.fn().mockResolvedValue({ count: 7, truncated: false }), + } as never); + }); + + it('runs query_detailed path on auto when user asks for content', async () => { + const server = createMockServer(); + registerGuidedQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'What does the paper say about contracts?', + namespace: 'papers', + top_k: 8, + preferred_tool: 'auto', + enrich_urls: false, + }) + ); + + expect(body.status).toBe('success'); + const trace = body.decision_trace as Record; + expect(trace.selected_namespace).toBe('papers'); + expect(trace.selected_tool).toBe('detailed'); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'papers', + topK: 8, + useReranking: true, + }) + ); + const result = body.result as Record; + expect(result.mode).toBe('query_detailed'); + }); + + it('runs count when preferred_tool is count', async () => { + const server = createMockServer(); + registerGuidedQueryTool(server as never); + const count = mockedGetClient().count as ReturnType; + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'browse', + namespace: 'papers', + preferred_tool: 'count', + }) + ); + + expect(count).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'browse', + namespace: 'papers', + }) + ); + const result = body.result as Record; + expect(result.tool).toBe('count'); + expect(result.count).toBe(7); + }); + + it('returns error when user_query is empty', async () => { + const server = createMockServer(); + registerGuidedQueryTool(server as never); + + const raw = await server.getHandler('guided_query')!({ + user_query: ' ', + namespace: 'papers', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(parseToolJson(raw).message).toBe('user_query cannot be empty'); + }); + + it('returns error when no namespace can be resolved', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerGuidedQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'hello world', + }) + ); + + expect(body.status).toBe('error'); + expect(String(body.message)).toContain('No namespace available'); + }); +}); diff --git a/src/server/tools/list-namespaces-tool.test.ts b/src/server/tools/list-namespaces-tool.test.ts new file mode 100644 index 0000000..639f8eb --- /dev/null +++ b/src/server/tools/list-namespaces-tool.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { registerListNamespacesTool } from './list-namespaces-tool.js'; +import { createMockServer, parseToolJson } from './test-helpers.js'; + +vi.mock('../namespaces-cache.js', () => ({ + getNamespacesWithCache: vi.fn(), +})); + +const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); + +describe('list_namespaces tool handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns success with namespaces on happy path', async () => { + const expiresAt = Date.now() + 1_800_000; + mockedGetNamespaces.mockResolvedValue({ + data: [ + { namespace: 'a', recordCount: 1, metadata: { title: 'string' } }, + { namespace: 'b', recordCount: 2, metadata: { url: 'string' } }, + ], + cache_hit: false, + expires_at: expiresAt, + }); + + const server = createMockServer(); + registerListNamespacesTool(server as never); + const handler = server.getHandler('list_namespaces')!; + const raw = await handler({}); + + const body = parseToolJson(raw); + expect(body.status).toBe('success'); + expect(body.cache_hit).toBe(false); + expect(body.count).toBe(2); + expect(body.namespaces).toEqual([ + { name: 'a', record_count: 1, metadata_fields: { title: 'string' } }, + { name: 'b', record_count: 2, metadata_fields: { url: 'string' } }, + ]); + expect(typeof body.cache_ttl_seconds).toBe('number'); + }); + + it('propagates cache_hit when namespaces cache is warm', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [{ namespace: 'x', recordCount: 0, metadata: {} }], + cache_hit: true, + expires_at: Date.now() + 60_000, + }); + + const server = createMockServer(); + registerListNamespacesTool(server as never); + const body = parseToolJson(await server.getHandler('list_namespaces')!({})); + + expect(body.cache_hit).toBe(true); + expect(body.count).toBe(1); + }); + + it('returns error payload when getNamespacesWithCache throws', async () => { + mockedGetNamespaces.mockRejectedValue(new Error('network down')); + + const server = createMockServer(); + registerListNamespacesTool(server as never); + const raw = await server.getHandler('list_namespaces')!({}); + const payload = raw as { isError?: boolean }; + + expect(payload.isError).toBe(true); + const body = parseToolJson(raw); + expect(body.status).toBe('error'); + expect(String(body.message)).toBe('Failed to list namespaces'); + }); +}); diff --git a/src/server/tools/query-documents-tool.test.ts b/src/server/tools/query-documents-tool.test.ts new file mode 100644 index 0000000..c68b452 --- /dev/null +++ b/src/server/tools/query-documents-tool.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_QUERY_DOCUMENTS_TOP_K, QUERY_DOCUMENTS_MAX_CHUNKS } from '../../constants.js'; +import { getPineconeClient } from '../client-context.js'; +import { reassembleByDocument } from '../reassemble-documents.js'; +import * as suggestionFlow from '../suggestion-flow.js'; +import { registerQueryDocumentsTool } from './query-documents-tool.js'; +import { createMockServer, makeSearchResult, parseToolJson } from './test-helpers.js'; + +vi.mock('../client-context.js', () => ({ + getPineconeClient: vi.fn(), +})); + +vi.mock('../reassemble-documents.js', () => ({ + reassembleByDocument: vi.fn(), +})); + +const mockedGetClient = vi.mocked(getPineconeClient); +const mockedReassemble = vi.mocked(reassembleByDocument); + +describe('query_documents tool handler', () => { + const flowOk = { + ok: true as const, + flow: { + updatedAt: Date.now(), + recommended_tool: 'detailed' as const, + suggested_fields: [], + user_query: 'q', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue(flowOk); + mockedReassemble.mockReturnValue([ + { + document_id: 'D1', + merged_content: 'full doc text', + metadata: { document_number: 'D1' }, + chunk_count: 3, + best_score: 0.99, + }, + ]); + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue([makeSearchResult()]), + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('happy path: queries chunks, reassembles, and returns documents', async () => { + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query_documents')!({ + query_text: 'semantic question', + namespace: 'wg21', + top_k: DEFAULT_QUERY_DOCUMENTS_TOP_K, + }) + ); + + const expectedTopK = Math.min(QUERY_DOCUMENTS_MAX_CHUNKS, DEFAULT_QUERY_DOCUMENTS_TOP_K * 50); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'semantic question', + namespace: 'wg21', + topK: expectedTopK, + useReranking: true, + fields: undefined, + }) + ); + expect(mockedReassemble).toHaveBeenCalled(); + expect(body.status).toBe('success'); + const docs = body.documents as Array<{ merged_content: string }>; + expect(docs[0].merged_content).toBe('full doc text'); + }); + + it('returns error when query_text is empty', async () => { + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const raw = await server.getHandler('query_documents')!({ + query_text: '', + namespace: 'wg21', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(query).not.toHaveBeenCalled(); + expect(parseToolJson(raw).message).toBe('query_text cannot be empty'); + }); + + it('returns flow error when suggest_query_params gate fails', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.', + }); + + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query_documents')!({ + query_text: 'ok', + namespace: 'wg21', + }) + ); + + expect(body.status).toBe('error'); + expect(query).not.toHaveBeenCalled(); + }); + + it('returns TTL expiry error from requireSuggested', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.', + }); + + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + + const body = parseToolJson( + await server.getHandler('query_documents')!({ + query_text: 'ok', + namespace: 'wg21', + }) + ); + + expect(body.message).toContain('expired'); + }); +}); diff --git a/src/server/tools/query-tool.test.ts b/src/server/tools/query-tool.test.ts new file mode 100644 index 0000000..fe40079 --- /dev/null +++ b/src/server/tools/query-tool.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FAST_QUERY_FIELDS } from '../../constants.js'; +import { getPineconeClient } from '../client-context.js'; +import * as suggestionFlow from '../suggestion-flow.js'; +import { registerQueryTool } from './query-tool.js'; +import { createMockServer, makeSearchResult, parseToolJson } from './test-helpers.js'; + +vi.mock('../client-context.js', () => ({ + getPineconeClient: vi.fn(), +})); + +const mockedGetClient = vi.mocked(getPineconeClient); + +describe('query tool handler (preset-driven)', () => { + const flowOk = { + ok: true as const, + flow: { + updatedAt: Date.now(), + recommended_tool: 'detailed' as const, + suggested_fields: ['chunk_text'], + user_query: 'q', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue(flowOk); + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue([makeSearchResult()]), + count: vi.fn(), + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('query (preset=full): happy path calls client.query and returns formatted rows', async () => { + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'contracts', + namespace: 'wg21', + top_k: 5, + preset: 'full', + use_reranking: true, + }) + ); + + expect(body.status).toBe('success'); + expect(body.mode).toBe('query'); + expect(body.result_count).toBe(1); + expect(Array.isArray(body.results)).toBe(true); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'contracts', + namespace: 'wg21', + topK: 5, + useReranking: true, + }) + ); + }); + + it('query (preset=fast): uses no reranking and default lightweight fields', async () => { + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'list', + namespace: 'wg21', + top_k: 10, + preset: 'fast', + }) + ); + + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + useReranking: false, + fields: [...FAST_QUERY_FIELDS], + }) + ); + expect(body.mode).toBe('query_fast'); + expect(body.status).toBe('success'); + }); + + it('query: rejects empty query_text before calling Pinecone', async () => { + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const raw = await server.getHandler('query')!({ + query_text: ' ', + namespace: 'wg21', + top_k: 10, + preset: 'full', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(query).not.toHaveBeenCalled(); + const body = parseToolJson(raw); + expect(body.message).toBe('Query text cannot be empty'); + }); + + it('query: returns flow error when suggest_query_params was not called first', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.', + }); + + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const raw = await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 10, + preset: 'full', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(query).not.toHaveBeenCalled(); + const body = parseToolJson(raw); + expect(body.message).toBe( + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.' + ); + }); + + it('query: returns TTL expiry message when suggestion context expired', async () => { + const expiredMsg = + 'Previous suggest_query_params context expired. Call suggest_query_params again before query/count tools.'; + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: expiredMsg, + }); + + const server = createMockServer(); + registerQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 10, + preset: 'full', + }) + ); + + expect(body.status).toBe('error'); + expect(body.message).toBe(expiredMsg); + }); + + it('query: surfaces unreranked hits when client returns reranked:false (rerank fallback shape)', async () => { + mockedGetClient.mockReturnValue({ + query: vi + .fn() + .mockResolvedValue([makeSearchResult({ reranked: false, score: 0.5, content: 'x' })]), + count: vi.fn(), + } as never); + + const server = createMockServer(); + registerQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 3, + preset: 'full', + }) + ); + + expect(body.status).toBe('success'); + const rows = body.results as Array<{ reranked: boolean }>; + expect(rows).toHaveLength(1); + expect(rows[0].reranked).toBe(false); + }); +}); diff --git a/src/server/tools/suggest-query-params-tool.test.ts b/src/server/tools/suggest-query-params-tool.test.ts new file mode 100644 index 0000000..3c6e317 --- /dev/null +++ b/src/server/tools/suggest-query-params-tool.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { markSuggested } from '../suggestion-flow.js'; +import { registerSuggestQueryParamsTool } from './suggest-query-params-tool.js'; +import { createMockServer, makeNamespaceCacheEntry, parseToolJson } from './test-helpers.js'; + +vi.mock('../namespaces-cache.js', () => ({ + getNamespacesWithCache: vi.fn(), +})); + +vi.mock('../suggestion-flow.js', () => ({ + markSuggested: vi.fn(), +})); + +const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); +const mockedMarkSuggested = vi.mocked(markSuggested); + +describe('suggest_query_params tool handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks suggestion flow and returns success when namespace exists in cache', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [makeNamespaceCacheEntry('wg21')], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const body = parseToolJson( + await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: 'List papers with titles', + }) + ); + + expect(body.status).toBe('success'); + expect(body.namespace_found).toBe(true); + expect(mockedMarkSuggested).toHaveBeenCalledTimes(1); + expect(mockedMarkSuggested).toHaveBeenCalledWith( + 'wg21', + expect.objectContaining({ + user_query: 'List papers with titles', + suggested_fields: expect.any(Array), + }) + ); + }); + + it('does not mark suggested when namespace is absent from cache', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [makeNamespaceCacheEntry('other')], + cache_hit: true, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const body = parseToolJson( + await server.getHandler('suggest_query_params')!({ + namespace: 'missing-ns', + user_query: 'anything', + }) + ); + + expect(body.namespace_found).toBe(false); + expect(mockedMarkSuggested).not.toHaveBeenCalled(); + }); + + it('returns error when user_query is empty', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const raw = await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: ' ', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + const body = parseToolJson(raw); + expect(body.status).toBe('error'); + expect(body.message).toBe('user_query cannot be empty'); + }); + + it('returns error when namespace cache fails', async () => { + mockedGetNamespaces.mockRejectedValue(new Error('cache boom')); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const raw = await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: 'hello', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + const body = parseToolJson(raw); + expect(body.status).toBe('error'); + expect(String(body.message)).toBe('Failed to suggest query params'); + }); +}); diff --git a/src/server/tools/test-helpers.ts b/src/server/tools/test-helpers.ts new file mode 100644 index 0000000..f0db291 --- /dev/null +++ b/src/server/tools/test-helpers.ts @@ -0,0 +1,65 @@ +import type { SearchResult } from '../../types.js'; + +/** Handler invoked by MCP tool registration (params shape varies by tool). */ +export type ToolHandler = (params: Record) => Promise; + +/** + * Minimal stand-in for {@link McpServer} that records `registerTool` handlers by name. + */ +export function createMockServer(): { + /** Matches {@link McpServer.registerTool}: `(name, config, callback)`; callback is always the last argument. */ + registerTool: (...args: unknown[]) => void; + getHandler: (name: string) => ToolHandler | undefined; + handlers: Map; +} { + const handlers = new Map(); + return { + registerTool(...args: unknown[]) { + if (args.length < 2) return; + const name = args[0]; + const handler = args[args.length - 1]; + if (typeof name !== 'string' || typeof handler !== 'function') return; + handlers.set(name, handler as ToolHandler); + }, + getHandler(name) { + return handlers.get(name); + }, + handlers, + }; +} + +/** Parse JSON body from {@link jsonResponse} / {@link jsonErrorResponse} payload. */ +export function parseToolJson(payload: unknown): Record { + const p = payload as { content: Array<{ type: string; text: string }> }; + const text = p.content[0]?.text; + if (typeof text !== 'string') { + throw new Error('Expected text content in tool response'); + } + return JSON.parse(text) as Record; +} + +export function makeSearchResult(overrides?: Partial): SearchResult { + return { + id: 'hit-1', + content: 'chunk body', + score: 0.95, + metadata: { document_number: 'WG21-P1234', title: 'T', author: 'A', url: 'https://x' }, + reranked: true, + ...overrides, + }; +} + +/** Shape returned by {@link getNamespacesWithCache} `data` entries. */ +export function makeNamespaceCacheEntry( + namespace: string, + metadata: Record = { + document_number: 'string', + title: 'string', + url: 'string', + author: 'string', + chunk_text: 'string', + }, + recordCount = 42 +): { namespace: string; recordCount: number; metadata: Record } { + return { namespace, recordCount, metadata }; +} diff --git a/vitest.config.ts b/vitest.config.ts index c89a157..fbae058 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,38 +6,8 @@ export default defineConfig({ environment: 'node', coverage: { provider: 'v8', - reporter: ['text', 'json', 'json-summary', 'html', 'lcov'], - include: ['src/**/*.ts'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.test.ts', - 'src/index.ts', - 'src/cli.ts', - 'src/types.ts', - // Large client: exercised by unit tests but not to saturation; omit from thresholds. - 'src/pinecone-client.ts', - 'src/server.ts', - 'src/config.ts', - 'src/server/tools/**', - 'src/server/client-context.ts', - 'src/server/config-context.ts', - 'src/server/format-query-result.ts', - 'src/server/namespaces-cache.ts', - 'src/server/namespace-router.ts', - 'src/server/suggestion-flow.ts', - 'src/server/tool-error.ts', - 'src/server/tool-response.ts', - 'src/server/tools/test-helpers.ts', - ], - // Thresholds apply to library-style modules covered by unit tests. MCP - // wiring (`server.ts`, tools, caches, suggestion flow) is excluded here. - thresholds: { - lines: 77, - statements: 76, - functions: 73, - branches: 58, - }, + reporter: ['text', 'json', 'html', 'lcov'], + exclude: ['node_modules/', 'dist/', '**/*.test.ts'], }, }, });