diff --git a/src/alliance/setup.ts b/src/alliance/setup.ts index 41ea1aa..44cfdf9 100644 --- a/src/alliance/setup.ts +++ b/src/alliance/setup.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ALLIANCE_SERVER_INSTRUCTIONS } from '../constants.js'; import type { ServerConfig } from '../core/config.js'; +import { getDefaultServerContext } from '../core/server/server-context.js'; import { resolveAllianceConfig } from './config.js'; import { setupCoreServer } from '../core/setup.js'; import { registerBuiltinUrlGenerators } from './url-builtins.js'; @@ -17,8 +18,9 @@ export async function setupAllianceServer(config?: ServerConfig): Promise { + it('returns success with decision_trace using injected context', async () => { + const listNamespacesWithMetadata = vi.fn().mockResolvedValue([ + { + namespace: 'papers', + recordCount: 42, + metadata: { + document_number: 'string', + title: 'string', + url: 'string', + author: 'string', + chunk_text: 'string', + }, + }, + ]); + const query = vi.fn().mockResolvedValue(makeHybridQueryResult()); + const ctx = createTestServerContext({ + client: { + listNamespacesWithMetadata, + query, + count: vi.fn().mockResolvedValue({ count: 7, truncated: false }), + } as never, + }); + + const server = createMockServer(); + registerGuidedQueryTool(server as never, ctx); + const raw = 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, + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + }); + const trace = body['decision_trace'] as Record; + expect(trace).toMatchObject({ + cache_hit: false, + selected_namespace: 'papers', + enrich_urls: false, + }); + expect(trace['rerank_status']).toBeDefined(); + expect(query).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/alliance/tools/guided-query-tool.ts b/src/alliance/tools/guided-query-tool.ts index ab3ff9b..df6939a 100644 --- a/src/alliance/tools/guided-query-tool.ts +++ b/src/alliance/tools/guided-query-tool.ts @@ -12,6 +12,7 @@ import { import { rankNamespacesByQuery } from '../../core/server/namespace-router.js'; import { getNamespacesWithCache } from '../../core/server/namespaces-cache.js'; import { normalizeNamespace } from '../../core/server/namespace-utils.js'; +import type { ServerContext } from '../../core/server/server-context.js'; import { suggestQueryParams } from '../../core/server/query-suggestion.js'; import { markSuggested } from '../../core/server/suggestion-flow.js'; import { @@ -39,7 +40,7 @@ function resolveGuidedToolName( * Registers `guided_query` (routing + suggestion + execution in one call). * See "Retrieval tool decision matrix" in README.md for tool-selection guidance. */ -export function registerGuidedQueryTool(server: McpServer): void { +export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'guided_query', { @@ -102,7 +103,9 @@ export function registerGuidedQueryTool(server: McpServer): void { } const queryText = user_query.trim(); - const { data: namespaces, cache_hit } = await getNamespacesWithCache(); + const { data: namespaces, cache_hit } = ctx + ? await ctx.getNamespacesWithCache() + : await getNamespacesWithCache(); const ranked = rankNamespacesByQuery(queryText, namespaces, 3); let namespace: string | null = null; @@ -150,13 +153,21 @@ export function registerGuidedQueryTool(server: McpServer): void { } const selectedTool: GuidedToolName = resolveGuidedToolName(preferred_tool, suggestion); - markSuggested(namespace, { - recommended_tool: selectedTool, - suggested_fields: suggestion.suggested_fields, - user_query: queryText, - }); + if (ctx) { + ctx.markSuggested(namespace, { + recommended_tool: selectedTool, + suggested_fields: suggestion.suggested_fields, + user_query: queryText, + }); + } else { + markSuggested(namespace, { + recommended_tool: selectedTool, + suggested_fields: suggestion.suggested_fields, + user_query: queryText, + }); + } - const client = getPineconeClient(); + const client = ctx ? ctx.getClient() : getPineconeClient(); const baseTrace = { cache_hit, input_namespace: inputNamespace ?? null, diff --git a/src/alliance/tools/suggest-query-params-tool.context.test.ts b/src/alliance/tools/suggest-query-params-tool.context.test.ts new file mode 100644 index 0000000..5539b63 --- /dev/null +++ b/src/alliance/tools/suggest-query-params-tool.context.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerSuggestQueryParamsTool } from './suggest-query-params-tool.js'; +import { + createMockServer, + createTestServerContext, + parseToolJson, +} from '../../core/server/tools/test-helpers.js'; + +describe('suggest_query_params tool handler (ServerContext instance path)', () => { + it('marks suggest-flow on injected context when namespace exists', async () => { + const listNamespacesWithMetadata = vi.fn().mockResolvedValue([ + { + namespace: 'wg21', + recordCount: 42, + metadata: { + document_number: 'string', + title: 'string', + url: 'string', + author: 'string', + chunk_text: 'string', + }, + }, + ]); + const ctx = createTestServerContext({ + client: { listNamespacesWithMetadata } as never, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never, ctx); + const raw = await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: 'List papers with titles', + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + namespace_found: true, + cache_hit: false, + }); + + const flowCheck = ctx.requireSuggested('wg21'); + expect(flowCheck.ok).toBe(true); + }); +}); diff --git a/src/alliance/tools/suggest-query-params-tool.ts b/src/alliance/tools/suggest-query-params-tool.ts index 1c21232..d059309 100644 --- a/src/alliance/tools/suggest-query-params-tool.ts +++ b/src/alliance/tools/suggest-query-params-tool.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { normalizeNamespace } from '../../core/server/namespace-utils.js'; import { getNamespacesWithCache } from '../../core/server/namespaces-cache.js'; import { suggestQueryParams } from '../../core/server/query-suggestion.js'; +import type { ServerContext } from '../../core/server/server-context.js'; import { markSuggested } from '../../core/server/suggestion-flow.js'; import { classifyToolCatchError, @@ -12,7 +13,7 @@ import { import { jsonErrorResponse, jsonResponse } from '../../core/server/tool-response.js'; /** Register the suggest_query_params tool on the MCP server. */ -export function registerSuggestQueryParamsTool(server: McpServer): void { +export function registerSuggestQueryParamsTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'suggest_query_params', { @@ -48,18 +49,28 @@ export function registerSuggestQueryParamsTool(server: McpServer): void { }) ); } - const { data: namespacesInfo, cache_hit } = await getNamespacesWithCache(); + const { data: namespacesInfo, cache_hit } = ctx + ? await ctx.getNamespacesWithCache() + : await getNamespacesWithCache(); const ns = namespacesInfo.find( (n) => n.namespace === nsNorm || normalizeNamespace(n.namespace) === nsNorm ); const metadataFields = ns?.metadata ?? null; const result = suggestQueryParams(metadataFields, user_query.trim()); if (result.namespace_found) { - markSuggested(nsNorm, { - recommended_tool: result.recommended_tool, - suggested_fields: result.suggested_fields, - user_query: user_query.trim(), - }); + if (ctx) { + ctx.markSuggested(nsNorm, { + recommended_tool: result.recommended_tool, + suggested_fields: result.suggested_fields, + user_query: user_query.trim(), + }); + } else { + markSuggested(nsNorm, { + recommended_tool: result.recommended_tool, + suggested_fields: result.suggested_fields, + user_query: user_query.trim(), + }); + } } const response = { ...result, diff --git a/src/core/server/tools/generate-urls-tool.context.test.ts b/src/core/server/tools/generate-urls-tool.context.test.ts new file mode 100644 index 0000000..28b18ce --- /dev/null +++ b/src/core/server/tools/generate-urls-tool.context.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { registerGenerateUrlsTool } from './generate-urls-tool.js'; +import { createMockServer, createTestServerContext, parseToolJson } from './test-helpers.js'; + +describe('generate_urls tool handler (ServerContext instance path)', () => { + it('uses URL generator registered on injected context', async () => { + const ctx = createTestServerContext(); + ctx.registerUrlGenerator('mailing', () => ({ + url: 'https://example.com/doc/P1234', + method: 'generator', + })); + + const server = createMockServer(); + registerGenerateUrlsTool(server as never, ctx); + const raw = await server.getHandler('generate_urls')!({ + namespace: 'mailing', + records: [{ document_number: 'P1234' }], + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + namespace: 'mailing', + count: 1, + }); + const results = body['results'] as Array<{ url: string; method: string }>; + expect(results[0]).toMatchObject({ + url: 'https://example.com/doc/P1234', + method: 'generator', + }); + }); +}); diff --git a/src/core/server/tools/generate-urls-tool.ts b/src/core/server/tools/generate-urls-tool.ts index 8ee5e2d..43a6b4b 100644 --- a/src/core/server/tools/generate-urls-tool.ts +++ b/src/core/server/tools/generate-urls-tool.ts @@ -1,7 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { generateUrlForNamespace } from '../url-registry.js'; import { normalizeNamespace } from '../namespace-utils.js'; +import type { ServerContext } from '../server-context.js'; +import { generateUrlForNamespace } from '../url-registry.js'; import { classifyToolCatchError, logToolError, validationToolError } from '../tool-error.js'; import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; @@ -15,7 +16,7 @@ function extractMetadata(record: Record): Record { const metadata = extractMetadata(record); - const generated = generateUrlForNamespace(nsNorm, metadata); + const generated = ctx + ? ctx.generateUrlForNamespace(nsNorm, metadata) + : generateUrlForNamespace(nsNorm, metadata); return { index, url: generated.url, diff --git a/src/core/server/tools/keyword-search-tool.context.test.ts b/src/core/server/tools/keyword-search-tool.context.test.ts new file mode 100644 index 0000000..491e31f --- /dev/null +++ b/src/core/server/tools/keyword-search-tool.context.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerKeywordSearchTool } from './keyword-search-tool.js'; +import { + createMockServer, + createTestServerContext, + makeSearchResult, + parseToolJson, +} from './test-helpers.js'; + +describe('keyword_search tool handler (ServerContext instance path)', () => { + it('returns success using injected client', async () => { + const keywordSearch = vi.fn().mockResolvedValue([makeSearchResult()]); + const ctx = createTestServerContext({ + client: { + keywordSearch, + getSparseIndexName: () => 'test-index-sparse', + } as never, + }); + + const server = createMockServer(); + registerKeywordSearchTool(server as never, ctx); + const raw = await server.getHandler('keyword_search')!({ + query_text: 'contracts', + namespace: 'wg21', + top_k: 5, + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + query: 'contracts', + namespace: 'wg21', + index: 'test-index-sparse', + result_count: 1, + }); + expect(keywordSearch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/core/server/tools/keyword-search-tool.ts b/src/core/server/tools/keyword-search-tool.ts index d7e2b89..7b801f3 100644 --- a/src/core/server/tools/keyword-search-tool.ts +++ b/src/core/server/tools/keyword-search-tool.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { MAX_TOP_K, MIN_TOP_K } from '../../../constants.js'; import { getPineconeClient } from '../client-context.js'; import { formatQueryResultRows } from '../format-query-result.js'; +import type { ServerContext } from '../server-context.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; import type { ToolError } from '../tool-error.js'; import { classifyToolCatchError, logToolError, validationToolError } from '../tool-error.js'; @@ -36,13 +37,16 @@ type KeywordSearchExecResult = | { ok: true; body: KeywordSearchResponse } | { ok: false; error: ToolError }; -async function executeKeywordSearch(params: { - query_text: string; - namespace: string; - top_k: number; - metadata_filter?: Record; - fields?: string[]; -}): Promise { +async function executeKeywordSearch( + params: { + query_text: string; + namespace: string; + top_k: number; + metadata_filter?: Record; + fields?: string[]; + }, + ctx?: ServerContext +): Promise { const { query_text, namespace, top_k, metadata_filter, fields } = params; const normalizedQuery = query_text.trim(); @@ -72,7 +76,7 @@ async function executeKeywordSearch(params: { } } - const client = getPineconeClient(); + const client = ctx ? ctx.getClient() : getPineconeClient(); const results = await client.keywordSearch({ query: normalizedQuery, namespace: normalizedNamespace, @@ -104,7 +108,7 @@ async function executeKeywordSearch(params: { * Registers `keyword_search` (lexical/sparse-only retrieval). * See "Retrieval tool decision matrix" in README.md for tool-selection guidance. */ -export function registerKeywordSearchTool(server: McpServer): void { +export function registerKeywordSearchTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'keyword_search', { @@ -137,13 +141,16 @@ export function registerKeywordSearchTool(server: McpServer): void { }, async (params) => { try { - const result = await executeKeywordSearch({ - query_text: params.query_text, - namespace: params.namespace, - top_k: params.top_k, - metadata_filter: params.metadata_filter, - fields: params.fields, - }); + const result = await executeKeywordSearch( + { + query_text: params.query_text, + namespace: params.namespace, + top_k: params.top_k, + metadata_filter: params.metadata_filter, + fields: params.fields, + }, + ctx + ); if (!result.ok) { return jsonErrorResponse(result.error); } diff --git a/src/core/server/tools/namespace-router-tool.context.test.ts b/src/core/server/tools/namespace-router-tool.context.test.ts new file mode 100644 index 0000000..c7f8004 --- /dev/null +++ b/src/core/server/tools/namespace-router-tool.context.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerNamespaceRouterTool } from './namespace-router-tool.js'; +import { createMockServer, createTestServerContext, parseToolJson } from './test-helpers.js'; + +describe('namespace_router tool handler (ServerContext instance path)', () => { + it('returns ranked suggestions from injected context cache miss', async () => { + const listNamespacesWithMetadata = vi.fn().mockResolvedValue([ + { + namespace: 'papers', + recordCount: 42, + metadata: { title: 'string', document_number: 'string' }, + }, + ]); + const ctx = createTestServerContext({ + client: { listNamespacesWithMetadata } as never, + }); + + const server = createMockServer(); + registerNamespaceRouterTool(server as never, ctx); + const raw = await server.getHandler('namespace_router')!({ + user_query: 'find cpp papers', + top_n: 3, + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + cache_hit: false, + user_query: 'find cpp papers', + recommended_namespace: 'papers', + }); + expect(body['suggestions']).toEqual( + expect.arrayContaining([expect.objectContaining({ namespace: 'papers' })]) + ); + expect(listNamespacesWithMetadata).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/core/server/tools/namespace-router-tool.ts b/src/core/server/tools/namespace-router-tool.ts index 319b0c7..e602e35 100644 --- a/src/core/server/tools/namespace-router-tool.ts +++ b/src/core/server/tools/namespace-router-tool.ts @@ -2,11 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { getNamespacesWithCache } from '../namespaces-cache.js'; import { rankNamespacesByQuery } from '../namespace-router.js'; +import type { ServerContext } from '../server-context.js'; import { classifyToolCatchError, logToolError, validationToolError } from '../tool-error.js'; import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; /** Register the namespace_router tool on the MCP server. */ -export function registerNamespaceRouterTool(server: McpServer): void { +export function registerNamespaceRouterTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'namespace_router', { @@ -32,7 +33,9 @@ export function registerNamespaceRouterTool(server: McpServer): void { if (!user_query?.trim()) { return jsonErrorResponse(validationToolError('user_query cannot be empty', 'user_query')); } - const { data, cache_hit } = await getNamespacesWithCache(); + const { data, cache_hit } = ctx + ? await ctx.getNamespacesWithCache() + : await getNamespacesWithCache(); const ranked = rankNamespacesByQuery(user_query.trim(), data, top_n); const response = { diff --git a/src/core/server/tools/query-documents-tool.context.test.ts b/src/core/server/tools/query-documents-tool.context.test.ts new file mode 100644 index 0000000..5d23032 --- /dev/null +++ b/src/core/server/tools/query-documents-tool.context.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerQueryDocumentsTool } from './query-documents-tool.js'; +import { + assertToolErrorCode, + createMockServer, + createTestServerContext, + makeHybridQueryResult, + parseToolJson, +} from './test-helpers.js'; + +describe('query_documents tool handler (ServerContext instance path)', () => { + it('returns success when flow is satisfied on injected context', async () => { + const query = vi.fn().mockResolvedValue(makeHybridQueryResult()); + const ctx = createTestServerContext({ + client: { query } as never, + }); + ctx.markSuggested('wg21', { + recommended_tool: 'detailed', + suggested_fields: [], + user_query: 'semantic question', + }); + + const server = createMockServer(); + registerQueryDocumentsTool(server as never, ctx); + const raw = await server.getHandler('query_documents')!({ + query_text: 'semantic question', + namespace: 'wg21', + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + namespace: 'wg21', + query: 'semantic question', + }); + expect(query).toHaveBeenCalledOnce(); + }); + + it('returns FLOW_GATE when injected context has no suggest-flow state', async () => { + const ctx = createTestServerContext({ + client: { query: vi.fn() } as never, + }); + const server = createMockServer(); + registerQueryDocumentsTool(server as never, ctx); + const raw = await server.getHandler('query_documents')!({ + query_text: 'semantic question', + namespace: 'wg21', + }); + const err = assertToolErrorCode(raw, 'FLOW_GATE'); + expect(err.suggestion).toBe("Call suggest_query_params for namespace 'wg21' first"); + }); +}); diff --git a/src/core/server/tools/query-documents-tool.ts b/src/core/server/tools/query-documents-tool.ts index 9e1c041..7b9db14 100644 --- a/src/core/server/tools/query-documents-tool.ts +++ b/src/core/server/tools/query-documents-tool.ts @@ -9,6 +9,7 @@ import { getPineconeClient } from '../client-context.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; import { normalizeNamespace } from '../namespace-utils.js'; import { reassembleByDocument } from '../reassemble-documents.js'; +import type { ServerContext } from '../server-context.js'; import { requireSuggested } from '../suggestion-flow.js'; import { classifyToolCatchError, @@ -31,7 +32,7 @@ const CHUNKS_PER_DOCUMENT = 50; * Registers `query_documents` (reassemble chunks into full documents). * See "Retrieval tool decision matrix" in README.md for tool-selection guidance. */ -export function registerQueryDocumentsTool(server: McpServer): void { +export function registerQueryDocumentsTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'query_documents', { @@ -101,13 +102,13 @@ export function registerQueryDocumentsTool(server: McpServer): void { ); } - const flowCheck = requireSuggested(nsNorm); + const flowCheck = ctx ? ctx.requireSuggested(nsNorm) : requireSuggested(nsNorm); if (!flowCheck.ok) { return jsonErrorResponse(flowGateToolError(nsNorm, flowCheck.message)); } const chunkLimit = Math.min(QUERY_DOCUMENTS_MAX_CHUNKS, top_k * CHUNKS_PER_DOCUMENT); - const client = getPineconeClient(); + const client = ctx ? ctx.getClient() : getPineconeClient(); const queryOutcome = await client.query({ query: query_text.trim(), topK: chunkLimit, diff --git a/src/core/setup.ts b/src/core/setup.ts index fe619e8..5ed279a 100644 --- a/src/core/setup.ts +++ b/src/core/setup.ts @@ -76,12 +76,12 @@ export async function setupCoreServer( ); registerListNamespacesTool(server, ctx); - registerNamespaceRouterTool(server); + registerNamespaceRouterTool(server, ctx); registerCountTool(server, ctx); registerQueryTool(server, ctx); - registerKeywordSearchTool(server); - registerQueryDocumentsTool(server); - registerGenerateUrlsTool(server); + registerKeywordSearchTool(server, ctx); + registerQueryDocumentsTool(server, ctx); + registerGenerateUrlsTool(server, ctx); mcpServerInitialized = true; return server;