feat(mcp): slim research tool surface

This commit is contained in:
Andrey Avtomonov 2026-05-16 02:11:01 +02:00
parent 2e77a669a9
commit 337c083f05
4 changed files with 148 additions and 1135 deletions

View file

@ -1,4 +1,6 @@
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import type { MemoryAgentInput } from '../memory/index.js';
import type { KtxMcpContextPorts, KtxMcpServerLike, KtxMcpToolResult, KtxMcpUserContext } from './types.js';
export interface RegisterKtxContextToolsDeps {
@ -11,10 +13,6 @@ const connectionIdSchema = z.string().min(1);
const connectionListSchema = z.object({});
const connectionTestSchema = z.object({
connectionId: connectionIdSchema,
});
const knowledgeSearchSchema = z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(50).default(10),
@ -24,55 +22,11 @@ const knowledgeReadSchema = z.object({
key: z.string().min(1),
});
const historicSqlUsageFrontmatterSchema = z.object({
executions: z.number().int().nonnegative(),
distinct_users: z.number().int().nonnegative(),
first_seen: z.string().min(1),
last_seen: z.string().min(1),
p50_runtime_ms: z.number().nonnegative().nullable(),
p95_runtime_ms: z.number().nonnegative().nullable(),
error_rate: z.number().min(0).max(1),
rows_produced: z.number().int().nonnegative().optional(),
});
const knowledgeWriteSchema = z.object({
key: z.string().min(1).max(120),
summary: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
refs: z.array(z.string()).optional(),
sl_refs: z.array(z.string()).optional(),
source: z.string().optional(),
intent: z.string().optional(),
tables: z.array(z.string()).optional(),
representative_sql: z.string().optional(),
usage: historicSqlUsageFrontmatterSchema.optional(),
fingerprints: z.array(z.string()).optional(),
});
const slListSourcesSchema = z.object({
connectionId: connectionIdSchema.optional(),
query: z.string().min(1).optional(),
});
const slReadSourceSchema = z.object({
connectionId: connectionIdSchema,
sourceName: z.string().min(1),
});
const slWriteSourceSchema = z.object({
connectionId: connectionIdSchema,
sourceName: z.string().regex(/^[a-z0-9][a-z0-9_]*$/, 'Source name must be snake_case'),
yaml: z.string().min(1).optional(),
source: z.record(z.string(), z.unknown()).optional(),
delete: z.boolean().optional(),
});
const slValidateSchema = z.object({
connectionId: connectionIdSchema,
names: z.array(z.string().min(1)).optional(),
});
const slQueryMeasureSchema = z.union([
z.string(),
z.object({
@ -131,41 +85,6 @@ const slQuerySchema = z.object({
include_empty: z.boolean().default(true),
});
const ingestTriggerSchema = z.object({
adapter: z.string().min(1),
connectionId: connectionIdSchema,
config: z.unknown().optional(),
trigger: z.enum(['upload', 'scheduled_pull', 'manual_resync']).default('manual_resync'),
});
const ingestStatusSchema = z.object({
runId: z.string().min(1),
});
const ingestReportSchema = z.object({
runId: z.string().min(1),
});
const ingestReplaySchema = z.object({
runId: z.string().min(1),
});
const scanTriggerSchema = z.object({
connectionId: connectionIdSchema,
mode: z.enum(['structural', 'relationships', 'enriched']).default('structural'),
detectRelationships: z.boolean().default(false),
dryRun: z.boolean().default(false),
});
const scanStatusSchema = z.object({
runId: z.string().min(1),
});
const scanArtifactReadSchema = z.object({
runId: z.string().min(1),
path: z.string().min(1),
});
const entityDetailsTableRefSchema = z.object({
catalog: z.string().nullable(),
db: z.string().nullable(),
@ -205,6 +124,24 @@ const sqlExecutionSchema = z.object({
maxRows: z.number().int().min(1).max(10_000).default(1000).optional(),
});
const memoryIngestSchema = z.object({
content: z
.string()
.min(1)
.describe(
'Free-form markdown to ingest. Include the knowledge itself plus any context (source, the user question, why this came up) that the memory agent should consider when triaging into wiki/SL.',
),
connectionId: connectionIdSchema
.optional()
.describe(
'Scope this memory to a specific connection. Required when the knowledge is warehouse-specific, including measure definitions, schema gotchas, or anything tied to a particular warehouse. Omit only for global wiki knowledge.',
),
});
const memoryIngestStatusSchema = z.object({
runId: z.string().min(1).describe('The memory ingest run id returned by memory_ingest.'),
});
export function jsonToolResult<T extends object>(structuredContent: T): KtxMcpToolResult<T> {
return {
content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
@ -245,25 +182,6 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
connectionListSchema,
async () => jsonToolResult({ connections: await connections.list() }),
);
if (connections.test) {
registerParsedTool(
server,
'connection_test',
{
title: 'Connection Test',
description: 'Test a configured standalone KTX connection through the host-provided scan connector.',
inputSchema: connectionTestSchema.shape,
},
connectionTestSchema,
async (input) => {
const result = await connections.test?.({ connectionId: input.connectionId });
return result
? jsonToolResult(result)
: jsonErrorToolResult(`Connection "${input.connectionId}" was not found.`);
},
);
}
}
if (ports.knowledge) {
@ -301,51 +219,10 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
return page ? jsonToolResult(page) : jsonErrorToolResult(`Wiki page "${input.key}" was not found.`);
},
);
registerParsedTool(
server,
'wiki_write',
{
title: 'Wiki Write',
description: 'Create or replace a KTX wiki page and its SL references.',
inputSchema: knowledgeWriteSchema.shape,
},
knowledgeWriteSchema,
async (input) =>
jsonToolResult(
await knowledge.write({
userId: userContext.userId,
key: input.key,
summary: input.summary,
content: input.content,
tags: input.tags,
refs: input.refs,
slRefs: input.sl_refs,
source: input.source,
intent: input.intent,
tables: input.tables,
representativeSql: input.representative_sql,
usage: input.usage,
fingerprints: input.fingerprints,
}),
),
);
}
if (ports.semanticLayer) {
const semanticLayer = ports.semanticLayer;
registerParsedTool(
server,
'sl_list_sources',
{
title: 'Semantic Layer List Sources',
description: 'List semantic-layer sources, optionally filtered by connection or search query.',
inputSchema: slListSourcesSchema.shape,
},
slListSourcesSchema,
async (input) => jsonToolResult(await semanticLayer.listSources(input)),
);
registerParsedTool(
server,
'sl_read_source',
@ -363,39 +240,6 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
);
registerParsedTool(
server,
'sl_write_source',
{
title: 'Semantic Layer Write Source',
description: 'Create, replace, or delete a semantic-layer source.',
inputSchema: slWriteSourceSchema.shape,
},
slWriteSourceSchema,
async (input) =>
jsonToolResult(
await semanticLayer.writeSource({
connectionId: input.connectionId,
sourceName: input.sourceName,
yaml: input.yaml,
source: input.source,
delete: input.delete,
}),
),
);
registerParsedTool(
server,
'sl_validate',
{
title: 'Semantic Layer Validate',
description: 'Validate semantic-layer sources for a connection.',
inputSchema: slValidateSchema.shape,
},
slValidateSchema,
async (input) => jsonToolResult(await semanticLayer.validate(input)),
);
registerParsedTool(
server,
'sl_query',
@ -501,149 +345,44 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
);
}
if (ports.ingest) {
const ingest = ports.ingest;
if (ports.memoryIngest) {
const memoryIngest = ports.memoryIngest;
registerParsedTool(
server,
'ingest_trigger',
'memory_ingest',
{
title: 'Ingest Trigger',
description: 'Trigger a KTX ingest run for an adapter and connection.',
inputSchema: ingestTriggerSchema.shape,
},
ingestTriggerSchema,
async (input) => jsonToolResult(await ingest.trigger(input)),
);
registerParsedTool(
server,
'ingest_status',
{
title: 'Ingest Status',
title: 'Memory Ingest',
description:
'Read the current or final status for an ingest run, including local diff and work-unit summaries when available.',
inputSchema: ingestStatusSchema.shape,
'Ingest free-form markdown knowledge into KTX durable memory. Use this for business rules, metric definitions, schema gotchas, recurring findings, or explicit user requests to remember something.',
inputSchema: memoryIngestSchema.shape,
},
ingestStatusSchema,
memoryIngestSchema,
async (input) => {
const status = await ingest.status(input);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Ingest run "${input.runId}" was not found.`);
},
);
if (ingest.report) {
registerParsedTool(
server,
'ingest_report',
{
title: 'Ingest Report',
description: 'Read the stored canonical KTX ingest report for a local run id, job id, or report id.',
inputSchema: ingestReportSchema.shape,
},
ingestReportSchema,
async (input) => {
const report = await ingest.report?.(input);
return report ? jsonToolResult(report) : jsonErrorToolResult(`Ingest report "${input.runId}" was not found.`);
},
);
}
if (ingest.replay) {
registerParsedTool(
server,
'ingest_replay',
{
title: 'Ingest Replay',
description: 'Read the memory-flow replay snapshot for a stored canonical KTX ingest run.',
inputSchema: ingestReplaySchema.shape,
},
ingestReplaySchema,
async (input) => {
const replay = await ingest.replay?.(input);
return replay ? jsonToolResult(replay) : jsonErrorToolResult(`Ingest replay "${input.runId}" was not found.`);
},
);
}
}
if (ports.scan) {
const scan = ports.scan;
registerParsedTool(
server,
'scan_trigger',
{
title: 'Scan Trigger',
description: 'Run a standalone KTX structural connection scan and return its report summary.',
inputSchema: scanTriggerSchema.shape,
},
scanTriggerSchema,
async (input) => jsonToolResult(await scan.trigger(input)),
);
registerParsedTool(
server,
'scan_status',
{
title: 'Scan Status',
description: 'Read the current or final status for a standalone KTX scan run.',
inputSchema: scanStatusSchema.shape,
},
scanStatusSchema,
async (input) => {
const status = await scan.status(input);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Scan run "${input.runId}" was not found.`);
const ingestInput: MemoryAgentInput = {
userId: userContext.userId,
chatId: `mcp-${randomUUID()}`,
userMessage: 'Ingest external knowledge into KTX memory.',
assistantMessage: input.content,
connectionId: input.connectionId,
sourceType: 'external_ingest',
};
return jsonToolResult(await memoryIngest.ingest(ingestInput));
},
);
registerParsedTool(
server,
'scan_report',
'memory_ingest_status',
{
title: 'Scan Report',
description: 'Read a standalone KTX scan report by run id.',
inputSchema: scanStatusSchema.shape,
title: 'Memory Ingest Status',
description: 'Read the current or final status for a memory ingest run.',
inputSchema: memoryIngestStatusSchema.shape,
},
scanStatusSchema,
memoryIngestStatusSchema,
async (input) => {
const report = await scan.report(input);
return report ? jsonToolResult(report) : jsonErrorToolResult(`Scan report "${input.runId}" was not found.`);
const status = await memoryIngest.status(input.runId);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory ingest run "${input.runId}" was not found.`);
},
);
if (scan.listArtifacts) {
registerParsedTool(
server,
'scan_list_artifacts',
{
title: 'Scan List Artifacts',
description: 'List report, raw-source, manifest, and enrichment artifact paths for a standalone KTX scan run.',
inputSchema: scanStatusSchema.shape,
},
scanStatusSchema,
async (input) => {
const result = await scan.listArtifacts?.({ runId: input.runId });
return result ? jsonToolResult(result) : jsonErrorToolResult(`Scan run "${input.runId}" was not found.`);
},
);
}
if (scan.readArtifact) {
registerParsedTool(
server,
'scan_read_artifact',
{
title: 'Scan Read Artifact',
description: 'Read one artifact that belongs to a standalone KTX scan run.',
inputSchema: scanArtifactReadSchema.shape,
},
scanArtifactReadSchema,
async (input) => {
const result = await scan.readArtifact?.({ runId: input.runId, path: input.path });
return result
? jsonToolResult(result)
: jsonErrorToolResult(`Scan artifact "${input.path}" was not found for run "${input.runId}".`);
},
);
}
}
}

View file

@ -2,21 +2,23 @@ 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';
import {
createLocalProjectMemoryIngest,
detectCaptureSignals,
type MemoryAgentInput,
} from '../memory/index.js';
import { initKtxProject } from '../project/index.js';
import { createKtxMcpServer } from './server.js';
import type {
KtxDiscoverDataMcpPort,
KtxDictionarySearchMcpPort,
KtxEntityDetailsMcpPort,
KtxIngestMcpPort,
KtxKnowledgeMcpPort,
KtxMcpContextPorts,
KtxScanMcpPort,
KtxSemanticLayerMcpPort,
KtxSqlExecutionMcpPort,
KtxSqlExecutionResponse,
MemoryCapturePort,
MemoryIngestPort,
} from './types.js';
type RegisteredTool = {
@ -259,10 +261,7 @@ describe('createKtxMcpServer', () => {
it('sl_query normalizes order_by from cube-style {id, desc} and bare strings to {field, direction}', async () => {
const fake = makeFakeServer();
const semanticLayer: KtxSemanticLayerMcpPort = {
listSources: vi.fn(),
readSource: vi.fn(),
writeSource: vi.fn(),
validate: vi.fn(),
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
sql: '',
headers: [],
@ -352,11 +351,15 @@ describe('createKtxMcpServer', () => {
});
});
it('registers memory capture tools without host app dependencies', async () => {
it('registers memory ingest tools through the context tool surface', async () => {
const fake = makeFakeServer();
const capture: MemoryCapturePort = {
capture: vi.fn<MemoryCapturePort['capture']>().mockResolvedValue({ runId: 'run-1' }),
status: vi.fn<MemoryCapturePort['status']>().mockResolvedValue({
let receivedInput: MemoryAgentInput | undefined;
const ingest: MemoryIngestPort = {
ingest: vi.fn<MemoryIngestPort['ingest']>().mockImplementation(async (input) => {
receivedInput = input;
return { runId: 'run-1' };
}),
status: vi.fn<MemoryIngestPort['status']>().mockResolvedValue({
runId: 'run-1',
status: 'done',
stage: 'done',
@ -371,33 +374,51 @@ describe('createKtxMcpServer', () => {
createKtxMcpServer({
server: fake.server,
memoryCapture: capture,
userContext: { userId: 'mcp-user' },
contextTools: { memoryIngest: ingest },
});
expect(fake.tools.map((tool) => tool.name).sort()).toEqual(['memory_capture', 'memory_capture_status']);
expect(fake.tools.map((tool) => tool.name).sort()).toEqual(['memory_ingest', 'memory_ingest_status']);
const memoryCapture = getTool(fake.tools, 'memory_capture');
const content = [
'view: orders {',
' sql_table_name: public.orders ;;',
' measure: gross_revenue {',
' type: sum',
' sql: ${TABLE}.gross_revenue_cents ;;',
' }',
'}',
].join('\n');
const memoryIngest = getTool(fake.tools, 'memory_ingest');
await expect(
memoryCapture.handler({
userMessage: 'Revenue means paid order value.',
assistantMessage: 'Captured.',
memoryIngest.handler({
content,
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({
expect(ingest.ingest).toHaveBeenCalledWith({
userId: 'mcp-user',
chatId: expect.stringMatching(/^mcp-/),
userMessage: 'Revenue means paid order value.',
assistantMessage: 'Captured.',
userMessage: 'Ingest external knowledge into KTX memory.',
assistantMessage: content,
connectionId: '00000000-0000-4000-8000-000000000001',
sourceType: 'external_ingest',
});
const memoryStatus = getTool(fake.tools, 'memory_capture_status');
const cliEquivalentInput: MemoryAgentInput = {
userId: 'mcp-user',
chatId: 'cli-text-ingest-test-1',
userMessage: 'Ingest external text artifact "orders lookml" into KTX memory.',
assistantMessage: content,
connectionId: '00000000-0000-4000-8000-000000000001',
sourceType: 'external_ingest',
};
expect(detectCaptureSignals(receivedInput!)).toEqual(detectCaptureSignals(cliEquivalentInput));
const memoryStatus = getTool(fake.tools, 'memory_ingest_status');
await expect(memoryStatus.handler({ runId: 'run-1' })).resolves.toEqual({
content: [
{
@ -433,36 +454,40 @@ describe('createKtxMcpServer', () => {
});
});
it('returns an MCP error payload for missing run ids', async () => {
it('returns an in-band error when a memory ingest run is missing', async () => {
const fake = makeFakeServer();
const capture: MemoryCapturePort = {
capture: vi.fn<MemoryCapturePort['capture']>(),
status: vi.fn<MemoryCapturePort['status']>().mockResolvedValue(null),
const ingest: MemoryIngestPort = {
ingest: vi.fn<MemoryIngestPort['ingest']>(),
status: vi.fn<MemoryIngestPort['status']>().mockResolvedValue(null),
};
createKtxMcpServer({
server: fake.server,
memoryCapture: capture,
userContext: { userId: 'mcp-user' },
contextTools: { memoryIngest: ingest },
});
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.' }],
const memoryStatus = getTool(fake.tools, 'memory_ingest_status');
await expect(memoryStatus.handler({ runId: 'missing-run' })).resolves.toEqual({
content: [{ type: 'text', text: 'Memory ingest run "missing-run" was not found.' }],
isError: true,
});
});
it('runs MCP memory_capture against a local project memory port', async () => {
it('runs MCP memory_ingest against a local project memory port', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-mcp-local-memory-'));
try {
const project = await initKtxProject({ projectDir: tempDir });
let receivedInput: MemoryAgentInput | undefined;
const agentRunner = {
runLoop: async ({
input,
toolSet,
}: {
input: MemoryAgentInput;
toolSet: Record<string, { execute: (input: unknown, options?: { toolCallId?: string }) => Promise<unknown> }>;
}) => {
receivedInput = input;
await toolSet.load_skill.execute({ name: 'wiki_capture' });
await toolSet.wiki_write.execute(
{
@ -475,7 +500,7 @@ describe('createKtxMcpServer', () => {
return { stopReason: 'natural' as const };
},
};
const memoryCapture = createLocalProjectMemoryCapture(project, {
const memoryIngest = createLocalProjectMemoryIngest(project, {
agentRunner: agentRunner as never,
runIdFactory: () => 'memory-run-mcp',
});
@ -483,21 +508,29 @@ describe('createKtxMcpServer', () => {
createKtxMcpServer({
server: fake.server,
memoryCapture,
userContext: { userId: 'mcp-user' },
userContext: { userId: 'local' },
contextTools: { memoryIngest },
});
const capture = await getTool(fake.tools, 'memory_capture').handler({
userMessage: 'define ARR as annual recurring revenue',
assistantMessage: 'Captured.',
const capture = await getTool(fake.tools, 'memory_ingest').handler({
content: 'Revenue means paid order value.',
connectionId: 'warehouse',
});
expect(capture).toMatchObject({
structuredContent: { runId: 'memory-run-mcp' },
});
await memoryCapture.waitForRun('memory-run-mcp');
await memoryIngest.waitForRun('memory-run-mcp');
expect(receivedInput).toMatchObject({
userId: 'local',
chatId: expect.stringMatching(/^mcp-/),
userMessage: 'Ingest external knowledge into KTX memory.',
assistantMessage: 'Revenue means paid order value.',
connectionId: 'warehouse',
sourceType: 'external_ingest',
});
await expect(
getTool(fake.tools, 'memory_capture_status').handler({ runId: 'memory-run-mcp' }),
getTool(fake.tools, 'memory_ingest_status').handler({ runId: 'memory-run-mcp' }),
).resolves.toMatchObject({
structuredContent: {
runId: 'memory-run-mcp',
@ -518,10 +551,6 @@ describe('createKtxMcpServer', () => {
it('registers KTX context MCP tools when context ports are supplied', async () => {
const fake = makeFakeServer();
const capture: MemoryCapturePort = {
capture: vi.fn<MemoryCapturePort['capture']>().mockResolvedValue({ runId: 'run-1' }),
status: vi.fn<MemoryCapturePort['status']>().mockResolvedValue(null),
};
const contextTools: KtxMcpContextPorts = {
connections: {
list: vi.fn().mockResolvedValue([
@ -531,14 +560,6 @@ describe('createKtxMcpServer', () => {
connectionType: 'POSTGRES',
},
]),
test: vi.fn().mockResolvedValue({
id: 'warehouse',
connectionType: 'postgres',
ok: true,
tableCount: 2,
message: 'Connection test passed.',
warnings: [],
}),
},
knowledge: {
search: vi.fn<KtxKnowledgeMcpPort['search']>().mockResolvedValue({
@ -563,42 +584,12 @@ describe('createKtxMcpServer', () => {
refs: [],
slRefs: ['orders'],
}),
write: vi.fn<KtxKnowledgeMcpPort['write']>().mockResolvedValue({
success: true,
key: 'revenue',
action: 'updated',
}),
},
semanticLayer: {
listSources: vi.fn<KtxSemanticLayerMcpPort['listSources']>().mockResolvedValue({
sources: [
{
connectionId: '00000000-0000-4000-8000-000000000001',
connectionName: 'Warehouse',
name: 'orders',
description: 'Order facts',
columnCount: 2,
measureCount: 1,
joinCount: 0,
},
],
totalSources: 1,
}),
readSource: vi.fn<KtxSemanticLayerMcpPort['readSource']>().mockResolvedValue({
sourceName: 'orders',
yaml: 'name: orders\n',
}),
writeSource: vi.fn<KtxSemanticLayerMcpPort['writeSource']>().mockResolvedValue({
success: true,
sourceName: 'orders',
yaml: 'name: orders\n',
commitHash: 'abc123',
}),
validate: vi.fn<KtxSemanticLayerMcpPort['validate']>().mockResolvedValue({
success: true,
errors: [],
warnings: [],
}),
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
sql: 'select 1',
headers: ['count'],
@ -607,221 +598,50 @@ describe('createKtxMcpServer', () => {
plan: { sources: ['orders'] },
}),
},
ingest: {
trigger: vi.fn<KtxIngestMcpPort['trigger']>().mockResolvedValue({
runId: 'run-42',
jobId: 'job-42',
reportId: 'report-42',
}),
status: vi.fn<KtxIngestMcpPort['status']>().mockResolvedValue({
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: [],
}),
report: vi.fn<NonNullable<KtxIngestMcpPort['report']>>().mockResolvedValue({
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: [],
},
}),
replay: vi.fn<NonNullable<KtxIngestMcpPort['replay']>>().mockResolvedValue({
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: [] },
entityDetails: {
read: vi.fn<KtxEntityDetailsMcpPort['read']>().mockResolvedValue({ results: [] }),
},
dictionarySearch: {
search: vi.fn<KtxDictionarySearchMcpPort['search']>().mockResolvedValue({
searched: [],
results: [],
}),
},
scan: {
trigger: vi.fn<KtxScanMcpPort['trigger']>().mockResolvedValue({
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',
},
}),
status: vi.fn<KtxScanMcpPort['status']>().mockResolvedValue({
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: [],
}),
report: vi.fn<KtxScanMcpPort['report']>().mockResolvedValue(null),
listArtifacts: vi.fn<NonNullable<KtxScanMcpPort['listArtifacts']>>().mockResolvedValue({
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,
},
],
}),
readArtifact: vi.fn<NonNullable<KtxScanMcpPort['readArtifact']>>().mockImplementation(async (input) => {
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',
};
discover: {
search: vi.fn<KtxDiscoverDataMcpPort['search']>().mockResolvedValue([]),
},
sqlExecution: {
execute: vi.fn<KtxSqlExecutionMcpPort['execute']>().mockResolvedValue({
headers: ['count'],
headerTypes: ['integer'],
rows: [[1]],
rowCount: 1,
}),
},
memoryIngest: {
ingest: vi.fn<MemoryIngestPort['ingest']>().mockResolvedValue({ runId: 'run-1' }),
status: vi.fn<MemoryIngestPort['status']>().mockResolvedValue(null),
},
};
createKtxMcpServer({
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',
'dictionary_search',
'discover_data',
'entity_details',
'memory_ingest',
'memory_ingest_status',
'sl_query',
'sl_read_source',
'sl_validate',
'sl_write_source',
'sql_execution',
'wiki_read',
'wiki_search',
'wiki_write',
]);
await expect(getTool(fake.tools, 'connection_list').handler({})).resolves.toEqual({
@ -854,35 +674,6 @@ describe('createKtxMcpServer', () => {
},
});
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 });
expect(contextTools.knowledge?.search).toHaveBeenCalledWith({
userId: 'mcp-user',
@ -896,33 +687,6 @@ describe('createKtxMcpServer', () => {
key: 'revenue',
});
await getTool(fake.tools, 'wiki_write').handler({
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',
@ -932,28 +696,6 @@ describe('createKtxMcpServer', () => {
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'],
@ -973,185 +715,5 @@ describe('createKtxMcpServer', () => {
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',
path: 'ktx.yaml',
}),
).resolves.toEqual({
content: [{ type: 'text', text: 'Scan artifact "ktx.yaml" was not found for run "scan-run-1".' }],
isError: true,
});
});
});

View file

@ -1,71 +1,8 @@
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { MemoryAgentInput } from '../memory/index.js';
import { jsonErrorToolResult, jsonToolResult, registerKtxContextTools } from './context-tools.js';
import type { KtxMcpServerDeps, KtxMcpServerLike, MemoryCapturePort } from './types.js';
const memoryCaptureInputSchema = {
userMessage: z.string().min(1).describe('The user message that may contain durable knowledge.'),
assistantMessage: z.string().optional().describe('The assistant response that concluded the exchange.'),
connectionId: z.string().min(1).optional().describe('Optional connection id for semantic-layer capture.'),
};
const memoryCaptureStatusInputSchema = {
runId: z.string().min(1).describe('The memory capture run id returned by memory_capture.'),
};
function registerMemoryCaptureTools(deps: {
server: KtxMcpServerLike;
memoryCapture: MemoryCapturePort;
userContext: KtxMcpServerDeps['userContext'];
}): void {
deps.server.registerTool(
'memory_capture',
{
title: 'Memory Capture',
description:
'Capture durable knowledge and semantic-layer updates from the final user/assistant exchange. Returns a run id for polling.',
inputSchema: memoryCaptureInputSchema,
},
async (input) => {
const captureInput: MemoryAgentInput = {
userId: deps.userContext.userId,
chatId: `mcp-${randomUUID()}`,
userMessage: String(input.userMessage),
assistantMessage: typeof input.assistantMessage === 'string' ? input.assistantMessage : undefined,
connectionId: typeof input.connectionId === 'string' ? input.connectionId : undefined,
sourceType: 'external_ingest',
};
const result = await deps.memoryCapture.capture(captureInput);
return jsonToolResult(result);
},
);
deps.server.registerTool(
'memory_capture_status',
{
title: 'Memory Capture Status',
description: 'Read the current or final status for a memory capture run.',
inputSchema: memoryCaptureStatusInputSchema,
},
async (input) => {
const runId = String(input.runId);
const status = await deps.memoryCapture.status(runId);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory capture run "${runId}" was not found.`);
},
);
}
import { registerKtxContextTools } from './context-tools.js';
import type { KtxMcpServerDeps, KtxMcpServerLike } from './types.js';
export function createKtxMcpServer(deps: KtxMcpServerDeps): KtxMcpServerDeps['server'] {
if (deps.memoryCapture) {
registerMemoryCaptureTools({
server: deps.server,
memoryCapture: deps.memoryCapture,
userContext: deps.userContext,
});
}
if (deps.contextTools) {
registerKtxContextTools({
server: deps.server,
@ -86,7 +23,6 @@ export function createDefaultKtxMcpServer(
});
createKtxMcpServer({
server: server as KtxMcpServerLike,
memoryCapture: deps.memoryCapture,
userContext: deps.userContext,
contextTools: deps.contextTools,
});

View file

@ -1,16 +1,7 @@
import type { IngestReportSnapshot, MemoryFlowReplayInput, TableUsageOutput } from '../ingest/index.js';
import type { MemoryCaptureService } from '../memory/index.js';
import type { MemoryIngestService } from '../memory/index.js';
import type { KtxEntityDetailsInput, KtxEntityDetailsResponse } from '../scan/entity-details.js';
import type { KtxScanMode, KtxScanReport } from '../scan/index.js';
import type { KtxDiscoverDataInput, KtxDiscoverDataResponse } from '../search/index.js';
import type {
KtxDictionarySearchInput,
KtxDictionarySearchResponse,
SemanticLayerQueryInput,
SlDictionaryMatch,
SlSearchLaneSummary,
SlSearchMatchReason,
} from '../sl/index.js';
import type { KtxDictionarySearchInput, KtxDictionarySearchResponse, SemanticLayerQueryInput } from '../sl/index.js';
import type { WikiSearchLaneSummary, WikiSearchMatchReason } from '../wiki/index.js';
export interface KtxMcpTextContent {
@ -24,9 +15,9 @@ export interface KtxMcpToolResult<T extends object = object> {
isError?: true;
}
export interface MemoryCapturePort {
capture: MemoryCaptureService['capture'];
status: MemoryCaptureService['status'];
export interface MemoryIngestPort {
ingest: MemoryIngestService['ingest'];
status: MemoryIngestService['status'];
}
export interface KtxMcpUserContext {
@ -51,18 +42,8 @@ export interface KtxConnectionSummary {
connectionType: string;
}
export interface KtxConnectionTestResponse {
id: string;
connectionType: string;
ok: boolean;
tableCount: number | null;
message: string;
warnings: string[];
}
export interface KtxConnectionsMcpPort {
list(): Promise<KtxConnectionSummary[]>;
test?(input: { connectionId: string }): Promise<KtxConnectionTestResponse | null>;
}
export interface KtxKnowledgeSearchResult {
@ -90,62 +71,9 @@ export interface KtxKnowledgePage {
slRefs?: string[];
}
interface KtxHistoricSqlKnowledgeUsage {
executions: number;
distinct_users: number;
first_seen: string;
last_seen: string;
p50_runtime_ms: number | null;
p95_runtime_ms: number | null;
error_rate: number;
rows_produced?: number;
}
export interface KtxKnowledgeWriteResponse {
success: boolean;
key: string;
action: 'created' | 'updated';
}
export interface KtxKnowledgeMcpPort {
search(input: { userId: string; query: string; limit: number }): Promise<KtxKnowledgeSearchResponse>;
read(input: { userId: string; key: string }): Promise<KtxKnowledgePage | null>;
write(input: {
userId: string;
key: string;
summary: string;
content: string;
tags?: string[];
refs?: string[];
slRefs?: string[];
source?: string;
intent?: string;
tables?: string[];
representativeSql?: string;
usage?: KtxHistoricSqlKnowledgeUsage;
fingerprints?: string[];
}): Promise<KtxKnowledgeWriteResponse>;
}
export interface KtxSemanticLayerSourceSummary {
connectionId: string;
connectionName: string;
name: string;
description?: string;
columnCount: number;
measureCount: number;
joinCount: number;
frequencyTier?: TableUsageOutput['frequencyTier'];
snippet?: string;
score?: number;
matchReasons?: SlSearchMatchReason[];
dictionaryMatches?: SlDictionaryMatch[];
lanes?: SlSearchLaneSummary[];
}
export interface KtxSemanticLayerListResponse {
sources: KtxSemanticLayerSourceSummary[];
totalSources: number;
}
export interface KtxSemanticLayerReadResponse {
@ -153,21 +81,6 @@ export interface KtxSemanticLayerReadResponse {
yaml: string;
}
export interface KtxSemanticLayerWriteResponse {
success: boolean;
sourceName: string;
yaml?: string;
errors?: string[];
warnings?: string[];
commitHash?: string;
}
export interface KtxSemanticLayerValidationResponse {
success: boolean;
errors: string[];
warnings: string[];
}
export interface KtxSemanticLayerQueryResponse {
sql: string;
headers: string[];
@ -177,145 +90,10 @@ export interface KtxSemanticLayerQueryResponse {
}
export interface KtxSemanticLayerMcpPort {
listSources(input: { connectionId?: string; query?: string }): Promise<KtxSemanticLayerListResponse>;
readSource(input: { connectionId: string; sourceName: string }): Promise<KtxSemanticLayerReadResponse | null>;
writeSource(input: {
connectionId: string;
sourceName: string;
yaml?: string;
source?: Record<string, unknown>;
delete?: boolean;
}): Promise<KtxSemanticLayerWriteResponse>;
validate(input: { connectionId: string; names?: string[] }): Promise<KtxSemanticLayerValidationResponse>;
query(input: { connectionId?: string; query: SemanticLayerQueryInput }): Promise<KtxSemanticLayerQueryResponse>;
}
export type KtxIngestTriggerKind = 'upload' | 'scheduled_pull' | 'manual_resync';
interface KtxIngestTriggerFanoutChild {
runId: string;
jobId: string;
reportId: string;
targetConnectionId: string;
metabaseDatabaseId: number;
}
export interface KtxIngestTriggerResponse {
runId: string;
jobId?: string;
reportId?: string;
fanout?: {
status: 'all_succeeded' | 'partial_failure' | 'all_failed';
children: KtxIngestTriggerFanoutChild[];
};
}
export interface KtxIngestDiffSummary {
added: number;
modified: number;
deleted: number;
unchanged: number;
}
export interface KtxIngestWorkUnitSummary {
unitKey: string;
rawFiles: string[];
peerFileIndex: string[];
dependencyPaths: string[];
}
export interface KtxIngestStatusResponse {
runId: string;
jobId?: string;
reportId?: string;
status: string;
stage?: string;
progress?: number;
errors?: string[];
done: boolean;
adapter?: string;
connectionId?: string;
sourceDir?: string | null;
syncId?: string;
startedAt?: string;
completedAt?: string;
previousRunId?: string | null;
diffSummary?: KtxIngestDiffSummary;
workUnitCount?: number;
rawFileCount?: number;
workUnits?: KtxIngestWorkUnitSummary[];
evictionDeletedRawPaths?: string[];
}
export interface KtxIngestMcpPort {
trigger(input: {
adapter: string;
connectionId: string;
config?: unknown;
trigger: KtxIngestTriggerKind;
}): Promise<KtxIngestTriggerResponse>;
status(input: { runId: string }): Promise<KtxIngestStatusResponse | null>;
report?(input: { runId: string }): Promise<IngestReportSnapshot | null>;
replay?(input: { runId: string }): Promise<MemoryFlowReplayInput | null>;
}
interface KtxScanTriggerResponse {
runId: string;
status: 'done';
done: true;
connectionId: string;
mode: KtxScanMode;
dryRun: boolean;
syncId: string;
report: KtxScanReport;
}
interface KtxScanStatusResponse {
runId: string;
status: string;
done: boolean;
connectionId: string;
mode: KtxScanMode;
dryRun: boolean;
syncId: string;
progress: number;
startedAt: string;
completedAt: string;
reportPath: string | null;
warnings: KtxScanReport['warnings'];
}
export type KtxScanArtifactType = 'report' | 'raw_source' | 'manifest_shard' | 'enrichment_artifact';
export interface KtxScanArtifactSummary {
path: string;
type: KtxScanArtifactType;
size?: number;
}
export interface KtxScanArtifactListResponse {
runId: string;
artifacts: KtxScanArtifactSummary[];
}
export interface KtxScanArtifactReadResponse extends KtxScanArtifactSummary {
runId: string;
content: string;
}
export interface KtxScanMcpPort {
trigger(input: {
connectionId: string;
mode?: KtxScanMode;
detectRelationships: boolean;
dryRun: boolean;
}): Promise<KtxScanTriggerResponse>;
status(input: { runId: string }): Promise<KtxScanStatusResponse | null>;
report(input: { runId: string }): Promise<KtxScanReport | null>;
listArtifacts?(input: { runId: string }): Promise<KtxScanArtifactListResponse | null>;
readArtifact?(input: { runId: string; path: string }): Promise<KtxScanArtifactReadResponse | null>;
}
export interface KtxEntityDetailsMcpPort {
read(input: KtxEntityDetailsInput): Promise<KtxEntityDetailsResponse>;
}
@ -347,13 +125,11 @@ export interface KtxMcpContextPorts {
dictionarySearch?: KtxDictionarySearchMcpPort;
discover?: KtxDiscoverDataMcpPort;
sqlExecution?: KtxSqlExecutionMcpPort;
ingest?: KtxIngestMcpPort;
scan?: KtxScanMcpPort;
memoryIngest?: MemoryIngestPort;
}
export interface KtxMcpServerDeps {
server: KtxMcpServerLike;
memoryCapture?: MemoryCapturePort;
userContext: KtxMcpUserContext;
contextTools?: KtxMcpContextPorts;
}