Add MCP agent client setup support

This commit is contained in:
Andrey Avtomonov 2026-05-16 00:12:36 +02:00
parent 658024dcf3
commit 0361449c8a
19 changed files with 745 additions and 146 deletions

View file

@ -191,6 +191,36 @@ describe('local KTX embedding config', () => {
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
});
it('returns null when backend is openai but no apiKey is resolvable from env', () => {
const config: KtxProjectEmbeddingConfig = {
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
};
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
});
it('resolves openai embedding config from env', () => {
const config: KtxProjectEmbeddingConfig = {
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
};
expect(
resolveLocalKtxEmbeddingConfig(config, { OPENAI_API_KEY: 'sk-test' }), // pragma: allowlist secret
).toEqual({
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { apiKey: 'sk-test' }, // pragma: allowlist secret
batchSize: undefined,
});
});
it('constructs deterministic embeddings from the default project config', () => {
const createKtxEmbeddingProvider = vi.fn(() => ({}) as never);
const provider = createLocalKtxEmbeddingProviderFromConfig(

View file

@ -145,11 +145,23 @@ export function resolveLocalKtxEmbeddingConfig(
batchSize: config.batchSize,
};
}
if (config.backend === 'openai') {
const openai = resolvedProviderConfig(config.openai, env);
if (!openai?.apiKey) {
return null;
}
return {
backend: config.backend,
model: config.model ?? 'deterministic',
dimensions: config.dimensions,
openai,
batchSize: config.batchSize,
};
}
return {
backend: config.backend,
model: config.model ?? 'deterministic',
dimensions: config.dimensions,
...(resolvedProviderConfig(config.openai, env) ? { openai: resolvedProviderConfig(config.openai, env) } : {}),
...(config.sentenceTransformers
? {
sentenceTransformers: {

View file

@ -89,13 +89,36 @@ const slQueryDimensionSchema = z.union([
}),
]);
const slQueryOrderBySchema = z.union([
z.string(),
const slQueryOrderBySchema = z.preprocess(
(value) => {
if (typeof value === 'string') {
return { field: value };
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = { ...(value as Record<string, unknown>) };
if (!('field' in obj) && typeof obj.id === 'string') {
obj.field = obj.id;
}
if (!('direction' in obj) && 'desc' in obj) {
obj.direction = obj.desc === true ? 'desc' : 'asc';
}
return obj;
}
return value;
},
z.object({
field: z.string().min(1),
direction: z.enum(['asc', 'desc']).default('asc'),
field: z
.string()
.min(1)
.describe(
'Field/measure/dimension id to order by, e.g. "orders.created_at", a dimension key like "mart_nrr_quarterly.quarter_label", or a measure alias.',
),
direction: z
.enum(['asc', 'desc'])
.default('asc')
.describe('Sort direction: "asc" or "desc". Defaults to "asc".'),
}),
]);
);
const slQuerySchema = z.object({
connectionId: connectionIdSchema.optional(),
@ -378,7 +401,10 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
'sl_query',
{
title: 'Semantic Layer Query',
description: 'Execute a semantic-layer query and return rows, headers, SQL, and the query plan.',
description:
'Execute a semantic-layer query and return rows, headers, SQL, and the query plan. ' +
'order_by items use the shape {"field": "orders.created_at", "direction": "asc"|"desc"}; ' +
'a bare string is treated as field with direction "asc".',
inputSchema: slQuerySchema.shape,
},
slQuerySchema,
@ -443,7 +469,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
inputSchema: discoverDataSchema.shape,
},
discoverDataSchema,
async (input) => jsonToolResult(await discover.search(input)),
async (input) => jsonToolResult({ refs: await discover.search(input) }),
);
}

View file

@ -256,6 +256,51 @@ 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: [],
rows: [],
totalRows: 0,
}),
};
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { semanticLayer },
});
await getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse',
measures: ['orders.count'],
order_by: [
{ field: 'orders.total', direction: 'desc' },
{ id: 'orders.quarter_label', desc: false },
{ id: 'orders.created_at', desc: true },
'orders.segment',
],
});
expect(semanticLayer.query).toHaveBeenCalledWith({
connectionId: 'warehouse',
query: expect.objectContaining({
order_by: [
{ field: 'orders.total', direction: 'desc' },
{ field: 'orders.quarter_label', direction: 'asc' },
{ field: 'orders.created_at', direction: 'desc' },
{ field: 'orders.segment', direction: 'asc' },
],
}),
});
});
it('registers discover_data when the host provides a discover port', async () => {
const fake = makeFakeServer();
const discover: KtxDiscoverDataMcpPort = {
@ -288,14 +333,16 @@ describe('createKtxMcpServer', () => {
limit: 5,
}),
).resolves.toMatchObject({
structuredContent: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
structuredContent: {
refs: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
},
});
expect(discover.search).toHaveBeenCalledWith({
query: 'orders',

View file

@ -182,6 +182,46 @@ grain: []
});
});
it('strips authoring-only fields (usage, inherits_columns_from) before sending sources to the daemon', async () => {
await project.fileStore.writeFile(
'semantic-layer/warehouse/_schema/public.yaml',
`tables:
invoices:
table: public.invoices
columns:
- name: invoice_id
type: number
pk: true
- name: amount
type: number
usage:
narrative: Activation policy windows table for invoice analytics.
frequencyTier: mid
commonFilters:
- amount
commonGroupBys: []
commonJoins: []
staleSince: null
`,
'ktx',
'ktx@example.com',
'Add manifest shard with usage',
);
await compileLocalSlQuery(project, {
connectionId: 'warehouse',
query: { measures: ['sum(invoices.amount)'], dimensions: [] },
compute,
});
const lastCall = (compute.query as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
const invoices = lastCall?.sources.find((s: Record<string, unknown>) => s.name === 'invoices');
expect(invoices).toBeDefined();
expect(invoices).not.toHaveProperty('usage');
expect(invoices).not.toHaveProperty('inherits_columns_from');
expect(invoices).not.toHaveProperty('source_type');
});
it('resolves the only configured connection when connectionId is omitted', async () => {
await compileLocalSlQuery(project, {
query: { measures: ['orders.order_count'], dimensions: [] },

View file

@ -2,6 +2,7 @@ import type { KtxSqlQueryExecutorPort } from '../connections/index.js';
import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
import type { KtxLocalProject } from '../project/index.js';
import { loadLocalSlSourceRecords } from './local-sl.js';
import { toResolvedWire } from './semantic-layer.service.js';
import type { SemanticLayerQueryExecutionResult, SemanticLayerQueryInput } from './types.js';
const COMPILE_ONLY_REASON =
@ -77,8 +78,8 @@ async function loadComputableSources(
connectionId: string,
): Promise<Record<string, unknown>[]> {
return (await loadLocalSlSourceRecords(project, { connectionId: assertSafeConnectionId(connectionId) }))
.map((record) => ({ ...record.source }))
.filter((source) => source.table || source.sql);
.filter((record) => record.source.table || record.source.sql)
.map((record) => toResolvedWire(record.source) as unknown as Record<string, unknown>);
}
function headersFromColumns(columns: Array<Record<string, unknown>>): string[] {