diff --git a/packages/context/src/mcp/context-tools.ts b/packages/context/src/mcp/context-tools.ts index 1d5e7009..a6ba2258 100644 --- a/packages/context/src/mcp/context-tools.ts +++ b/packages/context/src/mcp/context-tools.ts @@ -162,6 +162,11 @@ const entityDetailsSchema = z.object({ .max(20), }); +const dictionarySearchSchema = z.object({ + values: z.array(z.string().min(1)).min(1).max(20), + connectionId: connectionIdSchema.optional(), +}); + const sqlExecutionSchema = z.object({ connectionId: connectionIdSchema, sql: z.string().min(1), @@ -401,6 +406,22 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void ); } + if (ports.dictionarySearch) { + const dictionarySearch = ports.dictionarySearch; + registerParsedTool( + server, + 'dictionary_search', + { + title: 'Dictionary Search', + description: + 'Search profile-sampled warehouse values and report matching connection/source/column locations plus non-authoritative miss reasons.', + inputSchema: dictionarySearchSchema.shape, + }, + dictionarySearchSchema, + async (input) => jsonToolResult(await dictionarySearch.search(input)), + ); + } + if (ports.sqlExecution) { const sqlExecution = ports.sqlExecution; registerParsedTool( diff --git a/packages/context/src/mcp/index.ts b/packages/context/src/mcp/index.ts index 39e19726..a78d74ba 100644 --- a/packages/context/src/mcp/index.ts +++ b/packages/context/src/mcp/index.ts @@ -5,6 +5,7 @@ export { createDefaultKtxMcpServer, createKtxMcpServer } from './server.js'; export type { KtxConnectionSummary, KtxConnectionsMcpPort, + KtxDictionarySearchMcpPort, KtxEntityDetailsMcpPort, KtxIngestDiffSummary, KtxIngestMcpPort, diff --git a/packages/context/src/mcp/server.test.ts b/packages/context/src/mcp/server.test.ts index fed90c0d..b65b9747 100644 --- a/packages/context/src/mcp/server.test.ts +++ b/packages/context/src/mcp/server.test.ts @@ -6,6 +6,7 @@ import { createLocalProjectMemoryCapture } from '../memory/index.js'; import { initKtxProject } from '../project/index.js'; import { createKtxMcpServer } from './server.js'; import type { + KtxDictionarySearchMcpPort, KtxEntityDetailsMcpPort, KtxIngestMcpPort, KtxKnowledgeMcpPort, @@ -189,6 +190,71 @@ describe('createKtxMcpServer', () => { }); }); + it('registers dictionary_search when the host provides a dictionary-search port', async () => { + const fake = makeFakeServer(); + const dictionarySearch: KtxDictionarySearchMcpPort = { + search: vi.fn().mockResolvedValue({ + searched: [ + { + connectionId: 'warehouse', + coverage: { + sampledRows: null, + valuesPerColumn: null, + profiledColumns: 1, + syncId: 'sync-1', + profiledAt: null, + }, + status: 'ready', + }, + ], + results: [ + { + value: 'paid', + matches: [ + { + connectionId: 'warehouse', + sourceName: 'orders', + columnName: 'status', + matchedValue: 'paid', + cardinality: 3, + }, + ], + misses: [], + }, + ], + }), + }; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { dictionarySearch }, + }); + + expect(fake.tools.map((tool) => tool.name)).toEqual(['dictionary_search']); + await expect( + getTool(fake.tools, 'dictionary_search').handler({ + connectionId: 'warehouse', + values: ['paid'], + }), + ).resolves.toMatchObject({ + structuredContent: { + searched: [{ connectionId: 'warehouse', status: 'ready' }], + results: [ + { + value: 'paid', + matches: [{ connectionId: 'warehouse', sourceName: 'orders', columnName: 'status' }], + misses: [], + }, + ], + }, + }); + expect(dictionarySearch.search).toHaveBeenCalledWith({ + connectionId: 'warehouse', + values: ['paid'], + }); + }); + it('registers memory capture tools without host app dependencies', async () => { const fake = makeFakeServer(); const capture: MemoryCapturePort = { diff --git a/packages/context/src/mcp/types.ts b/packages/context/src/mcp/types.ts index b7bb1d3f..0171319f 100644 --- a/packages/context/src/mcp/types.ts +++ b/packages/context/src/mcp/types.ts @@ -3,6 +3,8 @@ import type { MemoryCaptureService } from '../memory/index.js'; import type { KtxEntityDetailsInput, KtxEntityDetailsResponse } from '../scan/entity-details.js'; import type { KtxScanMode, KtxScanReport } from '../scan/index.js'; import type { + KtxDictionarySearchInput, + KtxDictionarySearchResponse, SemanticLayerQueryInput, SlDictionaryMatch, SlSearchLaneSummary, @@ -317,6 +319,10 @@ export interface KtxEntityDetailsMcpPort { read(input: KtxEntityDetailsInput): Promise; } +export interface KtxDictionarySearchMcpPort { + search(input: KtxDictionarySearchInput): Promise; +} + export interface KtxSqlExecutionResponse { headers: string[]; headerTypes?: string[]; @@ -333,6 +339,7 @@ export interface KtxMcpContextPorts { knowledge?: KtxKnowledgeMcpPort; semanticLayer?: KtxSemanticLayerMcpPort; entityDetails?: KtxEntityDetailsMcpPort; + dictionarySearch?: KtxDictionarySearchMcpPort; sqlExecution?: KtxSqlExecutionMcpPort; ingest?: KtxIngestMcpPort; scan?: KtxScanMcpPort;