ktx/packages/context/src/mcp/server.test.ts

1111 lines
34 KiB
TypeScript
Raw Normal View History

2026-05-10 23:12:26 +02:00
import { access, mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { createLocalProjectMemoryCapture } from '../memory/index.js';
2026-05-10 23:51:24 +02:00
import { initKtxProject } from '../project/index.js';
import { createKtxMcpServer } from './server.js';
2026-05-10 23:12:26 +02:00
import type {
feat(mcp):added MCP server (#97) * docs(specs): design research-agent MCP tools and ktx mcp daemon Adds the 2026-05-14 design spec for exposing four new MCP tools (discover_data, entity_details, dictionary_search, sql_execution), shipping a ktx-research skill, and introducing an HTTP-only ktx mcp daemon so external agents can use KTX as a research-capable context layer. * Refine research-agent MCP tools spec after adversarial review iteration 1 * Refine research-agent MCP tools spec after adversarial review iteration 2 * Refine research-agent MCP tools spec after adversarial review iteration 3 * Refine spec: drop connectionName compat carve-out and ground summary/snippet provenance per kind * feat(daemon): validate read-only SQL with sqlglot * feat(context): expose read-only SQL validation port * feat(context): register MCP sql execution tool * feat(context): execute MCP SQL through validated connector path * test(context): update SQL analysis port fixtures * docs: add research-agent MCP sql execution foundation plan * feat(context): add scan-backed entity details service * feat(context): register MCP entity details tool * feat(context): expose local MCP entity details * test(context): align entity details scan fixtures * docs: add research-agent MCP entity_details plan * feat(context): add dictionary search service * feat(context): register MCP dictionary search tool * feat(context): expose local MCP dictionary search * docs: add research-agent MCP dictionary_search plan * feat: add MCP discover data service * feat: expose discover data MCP tool * feat: wire local discover data MCP port * docs: add research-agent MCP discover_data plan * feat(cli): add mcp http security helpers * feat(cli): host mcp over streamable http * feat(cli): manage mcp daemon lifecycle * feat(cli): add ktx mcp commands * fix(cli): stabilize mcp daemon verification * docs: add research-agent MCP http daemon plan * feat(cli): install KTX research skill * feat(cli): configure MCP clients in setup agents * feat(cli): support Claude local MCP setup scope * docs: add research-agent MCP setup-agents plan * refactor(context): use connectionId in warehouse verification tools * docs(context): update ingest verification prompts for connectionId * docs: add research-agent MCP ingest contract convergence plan * chore: build runtime artifacts in conductor setup --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-15 02:35:09 +02:00
KtxDiscoverDataMcpPort,
KtxDictionarySearchMcpPort,
KtxEntityDetailsMcpPort,
2026-05-10 23:51:24 +02:00
KtxIngestMcpPort,
KtxKnowledgeMcpPort,
KtxMcpContextPorts,
KtxScanMcpPort,
KtxSemanticLayerMcpPort,
feat(mcp):added MCP server (#97) * docs(specs): design research-agent MCP tools and ktx mcp daemon Adds the 2026-05-14 design spec for exposing four new MCP tools (discover_data, entity_details, dictionary_search, sql_execution), shipping a ktx-research skill, and introducing an HTTP-only ktx mcp daemon so external agents can use KTX as a research-capable context layer. * Refine research-agent MCP tools spec after adversarial review iteration 1 * Refine research-agent MCP tools spec after adversarial review iteration 2 * Refine research-agent MCP tools spec after adversarial review iteration 3 * Refine spec: drop connectionName compat carve-out and ground summary/snippet provenance per kind * feat(daemon): validate read-only SQL with sqlglot * feat(context): expose read-only SQL validation port * feat(context): register MCP sql execution tool * feat(context): execute MCP SQL through validated connector path * test(context): update SQL analysis port fixtures * docs: add research-agent MCP sql execution foundation plan * feat(context): add scan-backed entity details service * feat(context): register MCP entity details tool * feat(context): expose local MCP entity details * test(context): align entity details scan fixtures * docs: add research-agent MCP entity_details plan * feat(context): add dictionary search service * feat(context): register MCP dictionary search tool * feat(context): expose local MCP dictionary search * docs: add research-agent MCP dictionary_search plan * feat: add MCP discover data service * feat: expose discover data MCP tool * feat: wire local discover data MCP port * docs: add research-agent MCP discover_data plan * feat(cli): add mcp http security helpers * feat(cli): host mcp over streamable http * feat(cli): manage mcp daemon lifecycle * feat(cli): add ktx mcp commands * fix(cli): stabilize mcp daemon verification * docs: add research-agent MCP http daemon plan * feat(cli): install KTX research skill * feat(cli): configure MCP clients in setup agents * feat(cli): support Claude local MCP setup scope * docs: add research-agent MCP setup-agents plan * refactor(context): use connectionId in warehouse verification tools * docs(context): update ingest verification prompts for connectionId * docs: add research-agent MCP ingest contract convergence plan * chore: build runtime artifacts in conductor setup --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-15 02:35:09 +02:00
KtxSqlExecutionMcpPort,
KtxSqlExecutionResponse,
2026-05-10 23:12:26 +02:00
MemoryCapturePort,
} from './types.js';
type RegisteredTool = {
name: string;
config: { title?: string; description?: string; inputSchema: unknown };
handler: (input: Record<string, unknown>) => Promise<unknown>;
};
function makeFakeServer() {
const tools: RegisteredTool[] = [];
return {
tools,
server: {
registerTool(name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']): void {
tools.push({ name, config, handler });
},
},
};
}
function getTool(tools: RegisteredTool[], name: string): RegisteredTool {
const found = tools.find((tool) => tool.name === name);
if (!found) {
throw new Error(`Tool not registered: ${name}`);
}
return found;
}
2026-05-10 23:51:24 +02:00
describe('createKtxMcpServer', () => {
2026-05-10 23:12:26 +02:00
it('registers context tools without memory capture tools when memory capture is omitted', async () => {
const fake = makeFakeServer();
2026-05-10 23:51:24 +02:00
createKtxMcpServer({
2026-05-10 23:12:26 +02:00
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: {
connections: {
async list() {
return [{ id: 'warehouse', name: 'warehouse', connectionType: 'postgres' }];
},
},
},
});
expect(fake.tools.map((tool) => tool.name)).toEqual(['connection_list']);
await expect(getTool(fake.tools, 'connection_list').handler({})).resolves.toMatchObject({
structuredContent: {
connections: [{ id: 'warehouse', name: 'warehouse', connectionType: 'postgres' }],
},
});
});
feat(mcp):added MCP server (#97) * docs(specs): design research-agent MCP tools and ktx mcp daemon Adds the 2026-05-14 design spec for exposing four new MCP tools (discover_data, entity_details, dictionary_search, sql_execution), shipping a ktx-research skill, and introducing an HTTP-only ktx mcp daemon so external agents can use KTX as a research-capable context layer. * Refine research-agent MCP tools spec after adversarial review iteration 1 * Refine research-agent MCP tools spec after adversarial review iteration 2 * Refine research-agent MCP tools spec after adversarial review iteration 3 * Refine spec: drop connectionName compat carve-out and ground summary/snippet provenance per kind * feat(daemon): validate read-only SQL with sqlglot * feat(context): expose read-only SQL validation port * feat(context): register MCP sql execution tool * feat(context): execute MCP SQL through validated connector path * test(context): update SQL analysis port fixtures * docs: add research-agent MCP sql execution foundation plan * feat(context): add scan-backed entity details service * feat(context): register MCP entity details tool * feat(context): expose local MCP entity details * test(context): align entity details scan fixtures * docs: add research-agent MCP entity_details plan * feat(context): add dictionary search service * feat(context): register MCP dictionary search tool * feat(context): expose local MCP dictionary search * docs: add research-agent MCP dictionary_search plan * feat: add MCP discover data service * feat: expose discover data MCP tool * feat: wire local discover data MCP port * docs: add research-agent MCP discover_data plan * feat(cli): add mcp http security helpers * feat(cli): host mcp over streamable http * feat(cli): manage mcp daemon lifecycle * feat(cli): add ktx mcp commands * fix(cli): stabilize mcp daemon verification * docs: add research-agent MCP http daemon plan * feat(cli): install KTX research skill * feat(cli): configure MCP clients in setup agents * feat(cli): support Claude local MCP setup scope * docs: add research-agent MCP setup-agents plan * refactor(context): use connectionId in warehouse verification tools * docs(context): update ingest verification prompts for connectionId * docs: add research-agent MCP ingest contract convergence plan * chore: build runtime artifacts in conductor setup --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-15 02:35:09 +02:00
it('registers parser-gated sql_execution when the host provides a SQL execution port', async () => {
const fake = makeFakeServer();
const response: KtxSqlExecutionResponse = {
headers: ['status', 'count'],
headerTypes: ['text', 'bigint'],
rows: [['paid', 42]],
rowCount: 1,
};
const sqlExecution: KtxSqlExecutionMcpPort = {
execute: vi.fn<KtxSqlExecutionMcpPort['execute']>().mockResolvedValue(response),
};
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: {
sqlExecution,
},
});
expect(fake.tools.map((tool) => tool.name)).toEqual(['sql_execution']);
await expect(
getTool(fake.tools, 'sql_execution').handler({
connectionId: 'warehouse',
sql: 'select status, count(*) from public.orders group by status',
maxRows: 50,
}),
).resolves.toEqual({
content: [
{
type: 'text',
text: JSON.stringify(
{
headers: ['status', 'count'],
headerTypes: ['text', 'bigint'],
rows: [['paid', 42]],
rowCount: 1,
},
null,
2,
),
},
],
structuredContent: {
headers: ['status', 'count'],
headerTypes: ['text', 'bigint'],
rows: [['paid', 42]],
rowCount: 1,
},
});
expect(sqlExecution.execute).toHaveBeenCalledWith({
connectionId: 'warehouse',
sql: 'select status, count(*) from public.orders group by status',
maxRows: 50,
});
});
it('registers entity_details when the host provides an entity-details port', async () => {
const fake = makeFakeServer();
const entityDetails: KtxEntityDetailsMcpPort = {
read: vi.fn<KtxEntityDetailsMcpPort['read']>().mockResolvedValue({
results: [
{
ok: true,
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
display: 'public.orders',
kind: 'table',
comment: 'Customer orders',
estimatedRows: 12,
columns: [
{
name: 'id',
nativeType: 'integer',
normalizedType: 'integer',
dimensionType: 'number',
nullable: false,
primaryKey: true,
comment: null,
},
],
foreignKeys: [],
snapshot: {
syncId: 'sync-1',
extractedAt: '2026-05-14T09:00:00.000Z',
scanRunId: 'scan-1',
},
},
],
}),
};
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { entityDetails },
});
expect(fake.tools.map((tool) => tool.name)).toEqual(['entity_details']);
await expect(
getTool(fake.tools, 'entity_details').handler({
connectionId: 'warehouse',
entities: [{ table: 'public.orders', columns: ['id'] }],
}),
).resolves.toMatchObject({
structuredContent: {
results: [
{
ok: true,
connectionId: 'warehouse',
display: 'public.orders',
columns: [{ name: 'id' }],
},
],
},
});
expect(entityDetails.read).toHaveBeenCalledWith({
connectionId: 'warehouse',
entities: [{ table: 'public.orders', columns: ['id'] }],
});
});
it('registers dictionary_search when the host provides a dictionary-search port', async () => {
const fake = makeFakeServer();
const dictionarySearch: KtxDictionarySearchMcpPort = {
search: vi.fn<KtxDictionarySearchMcpPort['search']>().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 discover_data when the host provides a discover port', async () => {
const fake = makeFakeServer();
const discover: KtxDiscoverDataMcpPort = {
search: vi.fn<KtxDiscoverDataMcpPort['search']>().mockResolvedValue([
{
kind: 'table',
id: 'public.orders',
score: 1,
summary: 'Orders table',
snippet: 'id, status',
matchedOn: 'name',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
]),
};
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { discover },
});
expect(fake.tools.map((tool) => tool.name)).toEqual(['discover_data']);
await expect(
getTool(fake.tools, 'discover_data').handler({
query: 'orders',
connectionId: 'warehouse',
kinds: ['table'],
limit: 5,
}),
).resolves.toMatchObject({
structuredContent: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
});
expect(discover.search).toHaveBeenCalledWith({
query: 'orders',
connectionId: 'warehouse',
kinds: ['table'],
limit: 5,
});
});
2026-05-10 23:12:26 +02:00
it('registers memory capture tools without host app dependencies', async () => {
const fake = makeFakeServer();
const capture: MemoryCapturePort = {
capture: vi.fn<MemoryCapturePort['capture']>().mockResolvedValue({ runId: 'run-1' }),
status: vi.fn<MemoryCapturePort['status']>().mockResolvedValue({
runId: 'run-1',
status: 'done',
stage: 'done',
done: true,
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
error: null,
commitHash: 'abc123',
skillsLoaded: ['wiki_capture'],
2026-05-10 23:12:26 +02:00
signalDetected: true,
}),
};
2026-05-10 23:51:24 +02:00
createKtxMcpServer({
2026-05-10 23:12:26 +02:00
server: fake.server,
memoryCapture: capture,
userContext: { userId: 'mcp-user' },
});
expect(fake.tools.map((tool) => tool.name).sort()).toEqual(['memory_capture', 'memory_capture_status']);
const memoryCapture = getTool(fake.tools, 'memory_capture');
await expect(
memoryCapture.handler({
userMessage: 'Revenue means paid order value.',
assistantMessage: 'Captured.',
connectionId: '00000000-0000-4000-8000-000000000001',
}),
).resolves.toEqual({
content: [{ type: 'text', text: JSON.stringify({ runId: 'run-1' }, null, 2) }],
structuredContent: { runId: 'run-1' },
});
expect(capture.capture).toHaveBeenCalledWith({
userId: 'mcp-user',
chatId: expect.stringMatching(/^mcp-/),
userMessage: 'Revenue means paid order value.',
assistantMessage: 'Captured.',
connectionId: '00000000-0000-4000-8000-000000000001',
sourceType: 'external_ingest',
});
const memoryStatus = getTool(fake.tools, 'memory_capture_status');
await expect(memoryStatus.handler({ runId: 'run-1' })).resolves.toEqual({
content: [
{
type: 'text',
text: JSON.stringify(
{
runId: 'run-1',
status: 'done',
stage: 'done',
done: true,
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
error: null,
commitHash: 'abc123',
skillsLoaded: ['wiki_capture'],
2026-05-10 23:12:26 +02:00
signalDetected: true,
},
null,
2,
),
},
],
structuredContent: {
runId: 'run-1',
status: 'done',
stage: 'done',
done: true,
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
error: null,
commitHash: 'abc123',
skillsLoaded: ['wiki_capture'],
2026-05-10 23:12:26 +02:00
signalDetected: true,
},
});
});
it('returns an MCP error payload for missing run ids', async () => {
const fake = makeFakeServer();
const capture: MemoryCapturePort = {
capture: vi.fn<MemoryCapturePort['capture']>(),
status: vi.fn<MemoryCapturePort['status']>().mockResolvedValue(null),
};
2026-05-10 23:51:24 +02:00
createKtxMcpServer({
2026-05-10 23:12:26 +02:00
server: fake.server,
memoryCapture: capture,
userContext: { userId: 'mcp-user' },
});
const memoryStatus = getTool(fake.tools, 'memory_capture_status');
await expect(memoryStatus.handler({ runId: 'missing' })).resolves.toEqual({
content: [{ type: 'text', text: 'Memory capture run "missing" was not found.' }],
isError: true,
});
});
it('runs MCP memory_capture against a local project memory port', async () => {
2026-05-10 23:51:24 +02:00
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-mcp-local-memory-'));
2026-05-10 23:12:26 +02:00
try {
const project = await initKtxProject({ projectDir: tempDir });
2026-05-10 23:12:26 +02:00
const agentRunner = {
runLoop: async ({
toolSet,
}: {
toolSet: Record<string, { execute: (input: unknown, options?: { toolCallId?: string }) => Promise<unknown> }>;
}) => {
await toolSet.load_skill.execute({ name: 'wiki_capture' });
2026-05-10 23:12:26 +02:00
await toolSet.wiki_write.execute(
{
key: 'arr',
summary: 'ARR definition',
content: 'ARR means annual recurring revenue.',
},
{ toolCallId: 'wiki-write' },
);
return { stopReason: 'natural' as const };
},
};
const memoryCapture = createLocalProjectMemoryCapture(project, {
agentRunner: agentRunner as never,
runIdFactory: () => 'memory-run-mcp',
});
const fake = makeFakeServer();
2026-05-10 23:51:24 +02:00
createKtxMcpServer({
2026-05-10 23:12:26 +02:00
server: fake.server,
memoryCapture,
userContext: { userId: 'mcp-user' },
});
const capture = await getTool(fake.tools, 'memory_capture').handler({
userMessage: 'define ARR as annual recurring revenue',
assistantMessage: 'Captured.',
});
expect(capture).toMatchObject({
structuredContent: { runId: 'memory-run-mcp' },
});
await memoryCapture.waitForRun('memory-run-mcp');
await expect(
getTool(fake.tools, 'memory_capture_status').handler({ runId: 'memory-run-mcp' }),
).resolves.toMatchObject({
structuredContent: {
runId: 'memory-run-mcp',
status: 'done',
done: true,
captured: { wiki: ['arr'], sl: [], xrefs: [] },
},
});
2026-05-10 23:51:24 +02:00
await expect(access(join(project.projectDir, '.ktx/db.sqlite'))).resolves.toBeUndefined();
await expect(access(join(project.projectDir, '.ktx/memory-runs/memory-run-mcp.json'))).rejects.toThrow();
await expect(readFile(join(project.projectDir, 'wiki/global/arr.md'), 'utf-8')).resolves.toContain(
2026-05-10 23:12:26 +02:00
'ARR means annual recurring revenue.',
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
2026-05-10 23:51:24 +02:00
it('registers KTX context MCP tools when context ports are supplied', async () => {
2026-05-10 23:12:26 +02:00
const fake = makeFakeServer();
const capture: MemoryCapturePort = {
capture: vi.fn<MemoryCapturePort['capture']>().mockResolvedValue({ runId: 'run-1' }),
status: vi.fn<MemoryCapturePort['status']>().mockResolvedValue(null),
};
2026-05-10 23:51:24 +02:00
const contextTools: KtxMcpContextPorts = {
2026-05-10 23:12:26 +02:00
connections: {
list: vi.fn().mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000001',
name: 'Warehouse',
connectionType: 'POSTGRES',
},
]),
test: vi.fn().mockResolvedValue({
id: 'warehouse',
connectionType: 'postgres',
ok: true,
tableCount: 2,
message: 'Connection test passed.',
warnings: [],
}),
},
knowledge: {
2026-05-10 23:51:24 +02:00
search: vi.fn<KtxKnowledgeMcpPort['search']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
results: [
{
key: 'revenue',
path: 'wiki/global/revenue.md',
2026-05-10 23:12:26 +02:00
scope: 'GLOBAL',
summary: 'Paid order value',
score: 0.42,
matchReasons: ['lexical'],
},
],
totalFound: 1,
}),
2026-05-10 23:51:24 +02:00
read: vi.fn<KtxKnowledgeMcpPort['read']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
key: 'revenue',
summary: 'Paid order value',
content: '# Revenue',
scope: 'GLOBAL',
tags: ['finance'],
refs: [],
slRefs: ['orders'],
}),
2026-05-10 23:51:24 +02:00
write: vi.fn<KtxKnowledgeMcpPort['write']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
success: true,
key: 'revenue',
action: 'updated',
}),
},
semanticLayer: {
2026-05-10 23:51:24 +02:00
listSources: vi.fn<KtxSemanticLayerMcpPort['listSources']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
sources: [
{
connectionId: '00000000-0000-4000-8000-000000000001',
connectionName: 'Warehouse',
name: 'orders',
description: 'Order facts',
columnCount: 2,
measureCount: 1,
joinCount: 0,
},
],
totalSources: 1,
}),
2026-05-10 23:51:24 +02:00
readSource: vi.fn<KtxSemanticLayerMcpPort['readSource']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
sourceName: 'orders',
yaml: 'name: orders\n',
}),
2026-05-10 23:51:24 +02:00
writeSource: vi.fn<KtxSemanticLayerMcpPort['writeSource']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
success: true,
sourceName: 'orders',
yaml: 'name: orders\n',
commitHash: 'abc123',
}),
2026-05-10 23:51:24 +02:00
validate: vi.fn<KtxSemanticLayerMcpPort['validate']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
success: true,
errors: [],
warnings: [],
}),
2026-05-10 23:51:24 +02:00
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
sql: 'select 1',
headers: ['count'],
rows: [[1]],
totalRows: 1,
plan: { sources: ['orders'] },
}),
},
ingest: {
2026-05-10 23:51:24 +02:00
trigger: vi.fn<KtxIngestMcpPort['trigger']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
runId: 'run-42',
jobId: 'job-42',
reportId: 'report-42',
}),
2026-05-10 23:51:24 +02:00
status: vi.fn<KtxIngestMcpPort['status']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
runId: 'run-42',
jobId: 'job-42',
reportId: 'report-42',
status: 'done',
stage: 'done',
progress: 1,
done: true,
adapter: 'fake',
connectionId: 'warehouse',
sourceDir: '/tmp/upload',
syncId: '2026-04-27-120000-run-42',
startedAt: '2026-04-27T12:00:00.000Z',
completedAt: '2026-04-27T12:00:01.000Z',
previousRunId: 'run-41',
diffSummary: {
added: 0,
modified: 1,
deleted: 0,
unchanged: 3,
},
rawFileCount: 4,
workUnitCount: 1,
workUnits: [
{
unitKey: 'fake-orders',
rawFiles: ['orders/orders.json'],
peerFileIndex: [],
dependencyPaths: [],
},
],
evictionDeletedRawPaths: [],
errors: [],
}),
2026-05-10 23:51:24 +02:00
report: vi.fn<NonNullable<KtxIngestMcpPort['report']>>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
id: 'report-42',
runId: 'run-42',
jobId: 'job-42',
connectionId: 'warehouse',
sourceKey: 'fake',
createdAt: '2026-04-27T12:00:01.000Z',
body: {
syncId: '2026-04-27-120000-run-42',
diffSummary: { added: 0, modified: 1, deleted: 0, unchanged: 3 },
commitSha: null,
workUnits: [],
failedWorkUnits: [],
reconciliationSkipped: false,
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
evictionInputs: [],
unresolvedCards: [],
supersededBy: null,
overrideOf: null,
provenanceRows: [],
toolTranscripts: [],
},
}),
2026-05-10 23:51:24 +02:00
replay: vi.fn<NonNullable<KtxIngestMcpPort['replay']>>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
runId: 'run-42',
reportId: 'report-42',
reportPath: 'report-42',
connectionId: 'warehouse',
adapter: 'fake',
status: 'done',
sourceDir: null,
syncId: '2026-04-27-120000-run-42',
errors: [],
events: [{ type: 'report_created', runId: 'run-42', reportPath: 'report-42' }],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
}),
},
scan: {
2026-05-10 23:51:24 +02:00
trigger: vi.fn<KtxScanMcpPort['trigger']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report: {
connectionId: 'warehouse',
driver: 'postgres',
syncId: 'sync-1',
runId: 'scan-run-1',
trigger: 'mcp',
mode: 'structural',
dryRun: false,
artifactPaths: {
rawSourcesDir: 'raw-sources/warehouse/live-database/sync-1',
reportPath: 'raw-sources/warehouse/live-database/sync-1/scan-report.json',
manifestShards: [],
enrichmentArtifacts: [],
},
diffSummary: {
tablesAdded: 1,
tablesModified: 0,
tablesDeleted: 0,
tablesUnchanged: 0,
columnsAdded: 0,
columnsModified: 0,
columnsDeleted: 0,
},
manifestShardsWritten: 0,
structuralSyncStats: {
tablesCreated: 0,
tablesUpdated: 0,
tablesDeleted: 0,
columnsCreated: 0,
columnsUpdated: 0,
columnsDeleted: 0,
},
enrichment: {
dataDictionary: 'skipped',
tableDescriptions: 'skipped',
columnDescriptions: 'skipped',
embeddings: 'skipped',
deterministicRelationships: 'skipped',
llmRelationshipValidation: 'skipped',
statisticalValidation: 'skipped',
},
capabilityGaps: [],
warnings: [],
relationships: { accepted: 0, review: 0, rejected: 0, skipped: 0 },
enrichmentState: {
resumedStages: [],
completedStages: [],
failedStages: [],
},
createdAt: '2026-04-29T09:00:00.000Z',
},
}),
2026-05-10 23:51:24 +02:00
status: vi.fn<KtxScanMcpPort['status']>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
progress: 1,
startedAt: '2026-04-29T09:00:00.000Z',
completedAt: '2026-04-29T09:00:01.000Z',
reportPath: 'raw-sources/warehouse/live-database/sync-1/scan-report.json',
warnings: [],
}),
2026-05-10 23:51:24 +02:00
report: vi.fn<KtxScanMcpPort['report']>().mockResolvedValue(null),
listArtifacts: vi.fn<NonNullable<KtxScanMcpPort['listArtifacts']>>().mockResolvedValue({
2026-05-10 23:12:26 +02:00
runId: 'scan-run-1',
artifacts: [
{
path: 'raw-sources/warehouse/live-database/sync-1/scan-report.json',
type: 'report',
size: 128,
},
{
path: 'raw-sources/warehouse/live-database/sync-1/tables/orders.json',
type: 'raw_source',
size: 64,
},
],
}),
2026-05-10 23:51:24 +02:00
readArtifact: vi.fn<NonNullable<KtxScanMcpPort['readArtifact']>>().mockImplementation(async (input) => {
2026-05-10 23:12:26 +02:00
if (input.path !== 'raw-sources/warehouse/live-database/sync-1/tables/orders.json') {
return null;
}
return {
runId: input.runId,
path: input.path,
type: 'raw_source',
size: 64,
content: '{"name":"orders"}\n',
};
}),
},
};
2026-05-10 23:51:24 +02:00
createKtxMcpServer({
2026-05-10 23:12:26 +02:00
server: fake.server,
memoryCapture: capture,
userContext: { userId: 'mcp-user' },
contextTools,
});
expect(fake.tools.map((tool) => tool.name).sort()).toEqual([
'connection_list',
'connection_test',
'ingest_replay',
'ingest_report',
'ingest_status',
'ingest_trigger',
'memory_capture',
'memory_capture_status',
'scan_list_artifacts',
'scan_read_artifact',
'scan_report',
'scan_status',
'scan_trigger',
'sl_list_sources',
'sl_query',
'sl_read_source',
'sl_validate',
'sl_write_source',
'wiki_read',
'wiki_search',
'wiki_write',
2026-05-10 23:12:26 +02:00
]);
await expect(getTool(fake.tools, 'connection_list').handler({})).resolves.toEqual({
content: [
{
type: 'text',
text: JSON.stringify(
{
connections: [
{
id: '00000000-0000-4000-8000-000000000001',
name: 'Warehouse',
connectionType: 'POSTGRES',
},
],
},
null,
2,
),
},
],
structuredContent: {
connections: [
{
id: '00000000-0000-4000-8000-000000000001',
name: 'Warehouse',
connectionType: 'POSTGRES',
},
],
},
});
await expect(getTool(fake.tools, 'connection_test').handler({ connectionId: 'warehouse' })).resolves.toEqual({
content: [
{
type: 'text',
text: JSON.stringify(
{
id: 'warehouse',
connectionType: 'postgres',
ok: true,
tableCount: 2,
message: 'Connection test passed.',
warnings: [],
},
null,
2,
),
},
],
structuredContent: {
id: 'warehouse',
connectionType: 'postgres',
ok: true,
tableCount: 2,
message: 'Connection test passed.',
warnings: [],
},
});
expect(contextTools.connections?.test).toHaveBeenCalledWith({ connectionId: 'warehouse' });
await getTool(fake.tools, 'wiki_search').handler({ query: 'revenue', limit: 5 });
2026-05-10 23:12:26 +02:00
expect(contextTools.knowledge?.search).toHaveBeenCalledWith({
userId: 'mcp-user',
query: 'revenue',
limit: 5,
});
await getTool(fake.tools, 'wiki_read').handler({ key: 'revenue' });
2026-05-10 23:12:26 +02:00
expect(contextTools.knowledge?.read).toHaveBeenCalledWith({
userId: 'mcp-user',
key: 'revenue',
});
await getTool(fake.tools, 'wiki_write').handler({
2026-05-10 23:12:26 +02:00
key: 'revenue',
summary: 'Paid order value',
content: '# Revenue',
tags: ['finance'],
refs: ['gross-margin'],
sl_refs: ['orders'],
});
expect(contextTools.knowledge?.write).toHaveBeenCalledWith({
userId: 'mcp-user',
key: 'revenue',
summary: 'Paid order value',
content: '# Revenue',
tags: ['finance'],
refs: ['gross-margin'],
slRefs: ['orders'],
});
await getTool(fake.tools, 'sl_list_sources').handler({
connectionId: '00000000-0000-4000-8000-000000000001',
query: 'orders',
});
expect(contextTools.semanticLayer?.listSources).toHaveBeenCalledWith({
connectionId: '00000000-0000-4000-8000-000000000001',
query: 'orders',
});
await getTool(fake.tools, 'sl_read_source').handler({
connectionId: 'warehouse',
sourceName: 'orders',
});
expect(contextTools.semanticLayer?.readSource).toHaveBeenCalledWith({
connectionId: 'warehouse',
sourceName: 'orders',
});
await getTool(fake.tools, 'sl_write_source').handler({
connectionId: '00000000-0000-4000-8000-000000000001',
sourceName: 'orders',
source: { name: 'orders', table: 'public.orders', grain: ['id'], columns: [], joins: [], measures: [] },
});
expect(contextTools.semanticLayer?.writeSource).toHaveBeenCalledWith({
connectionId: '00000000-0000-4000-8000-000000000001',
sourceName: 'orders',
source: { name: 'orders', table: 'public.orders', grain: ['id'], columns: [], joins: [], measures: [] },
yaml: undefined,
delete: undefined,
});
await getTool(fake.tools, 'sl_validate').handler({
connectionId: '00000000-0000-4000-8000-000000000001',
names: ['orders'],
});
expect(contextTools.semanticLayer?.validate).toHaveBeenCalledWith({
connectionId: '00000000-0000-4000-8000-000000000001',
names: ['orders'],
});
await getTool(fake.tools, 'sl_query').handler({
connectionId: '00000000-0000-4000-8000-000000000001',
measures: ['orders.count'],
dimensions: ['orders.created_at'],
filters: ['orders.status = paid'],
limit: 25,
});
expect(contextTools.semanticLayer?.query).toHaveBeenCalledWith({
connectionId: '00000000-0000-4000-8000-000000000001',
query: {
measures: ['orders.count'],
dimensions: ['orders.created_at'],
filters: ['orders.status = paid'],
segments: [],
order_by: [],
limit: 25,
include_empty: true,
},
});
await getTool(fake.tools, 'ingest_trigger').handler({
adapter: 'lookml',
connectionId: '00000000-0000-4000-8000-000000000001',
trigger: 'scheduled_pull',
config: { repoUrl: 'https://github.com/acme/looker.git' },
});
expect(contextTools.ingest?.trigger).toHaveBeenCalledWith({
adapter: 'lookml',
connectionId: '00000000-0000-4000-8000-000000000001',
trigger: 'scheduled_pull',
config: { repoUrl: 'https://github.com/acme/looker.git' },
});
expect(getTool(fake.tools, 'ingest_status').config.description).toBe(
'Read the current or final status for an ingest run, including local diff and work-unit summaries when available.',
);
await expect(getTool(fake.tools, 'ingest_status').handler({ runId: 'run-42' })).resolves.toMatchObject({
structuredContent: {
runId: 'run-42',
status: 'done',
stage: 'done',
progress: 1,
done: true,
adapter: 'fake',
connectionId: 'warehouse',
sourceDir: '/tmp/upload',
syncId: '2026-04-27-120000-run-42',
previousRunId: 'run-41',
diffSummary: {
added: 0,
modified: 1,
deleted: 0,
unchanged: 3,
},
rawFileCount: 4,
workUnitCount: 1,
workUnits: [
{
unitKey: 'fake-orders',
rawFiles: ['orders/orders.json'],
peerFileIndex: [],
dependencyPaths: [],
},
],
evictionDeletedRawPaths: [],
errors: [],
},
});
expect(contextTools.ingest?.status).toHaveBeenCalledWith({ runId: 'run-42' });
await expect(getTool(fake.tools, 'ingest_report').handler({ runId: 'report-42' })).resolves.toMatchObject({
structuredContent: {
id: 'report-42',
runId: 'run-42',
jobId: 'job-42',
sourceKey: 'fake',
},
});
expect(contextTools.ingest?.report).toHaveBeenCalledWith({ runId: 'report-42' });
await expect(getTool(fake.tools, 'ingest_replay').handler({ runId: 'run-42' })).resolves.toMatchObject({
structuredContent: {
runId: 'run-42',
reportId: 'report-42',
status: 'done',
adapter: 'fake',
},
});
expect(contextTools.ingest?.replay).toHaveBeenCalledWith({ runId: 'run-42' });
await getTool(fake.tools, 'scan_trigger').handler({
connectionId: 'warehouse',
mode: 'structural',
dryRun: true,
});
expect(contextTools.scan?.trigger).toHaveBeenCalledWith({
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: true,
});
await getTool(fake.tools, 'scan_trigger').handler({
connectionId: 'warehouse',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
});
expect(contextTools.scan?.trigger).toHaveBeenCalledWith({
connectionId: 'warehouse',
mode: 'relationships',
detectRelationships: true,
dryRun: false,
});
await expect(getTool(fake.tools, 'scan_status').handler({ runId: 'scan-run-1' })).resolves.toMatchObject({
structuredContent: {
runId: 'scan-run-1',
status: 'done',
connectionId: 'warehouse',
},
});
await expect(getTool(fake.tools, 'scan_report').handler({ runId: 'missing' })).resolves.toEqual({
content: [{ type: 'text', text: 'Scan report "missing" was not found.' }],
isError: true,
});
await expect(getTool(fake.tools, 'scan_list_artifacts').handler({ runId: 'scan-run-1' })).resolves.toEqual({
content: [
{
type: 'text',
text: JSON.stringify(
{
runId: 'scan-run-1',
artifacts: [
{
path: 'raw-sources/warehouse/live-database/sync-1/scan-report.json',
type: 'report',
size: 128,
},
{
path: 'raw-sources/warehouse/live-database/sync-1/tables/orders.json',
type: 'raw_source',
size: 64,
},
],
},
null,
2,
),
},
],
structuredContent: {
runId: 'scan-run-1',
artifacts: [
{
path: 'raw-sources/warehouse/live-database/sync-1/scan-report.json',
type: 'report',
size: 128,
},
{
path: 'raw-sources/warehouse/live-database/sync-1/tables/orders.json',
type: 'raw_source',
size: 64,
},
],
},
});
expect(contextTools.scan?.listArtifacts).toHaveBeenCalledWith({ runId: 'scan-run-1' });
await expect(
getTool(fake.tools, 'scan_read_artifact').handler({
runId: 'scan-run-1',
path: 'raw-sources/warehouse/live-database/sync-1/tables/orders.json',
}),
).resolves.toMatchObject({
structuredContent: {
runId: 'scan-run-1',
path: 'raw-sources/warehouse/live-database/sync-1/tables/orders.json',
type: 'raw_source',
content: '{"name":"orders"}\n',
},
});
expect(contextTools.scan?.readArtifact).toHaveBeenCalledWith({
runId: 'scan-run-1',
path: 'raw-sources/warehouse/live-database/sync-1/tables/orders.json',
});
await expect(
getTool(fake.tools, 'scan_read_artifact').handler({
runId: 'scan-run-1',
2026-05-10 23:51:24 +02:00
path: 'ktx.yaml',
2026-05-10 23:12:26 +02:00
}),
).resolves.toEqual({
2026-05-10 23:51:24 +02:00
content: [{ type: 'text', text: 'Scan artifact "ktx.yaml" was not found for run "scan-run-1".' }],
2026-05-10 23:12:26 +02:00
isError: true,
});
});
});