mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat(mcp): slim research tool surface
This commit is contained in:
parent
2e77a669a9
commit
337c083f05
4 changed files with 148 additions and 1135 deletions
|
|
@ -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}".`);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue