mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
Add MCP agent client setup support
This commit is contained in:
parent
658024dcf3
commit
0361449c8a
19 changed files with 745 additions and 146 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue