mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat: trim MCP query response payloads (#240)
This commit is contained in:
parent
cbbcf8e8bd
commit
25f639fba2
7 changed files with 235 additions and 1703 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
KtxMcpToolHandlerContext,
|
KtxMcpToolHandlerContext,
|
||||||
KtxMcpToolResult,
|
KtxMcpToolResult,
|
||||||
KtxMcpUserContext,
|
KtxMcpUserContext,
|
||||||
|
KtxSemanticLayerQueryResponse,
|
||||||
NonArrayObject,
|
NonArrayObject,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
|
|
@ -60,7 +61,7 @@ const toolDescriptions = {
|
||||||
sl_read_source:
|
sl_read_source:
|
||||||
'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).',
|
'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).',
|
||||||
sl_query:
|
sl_query:
|
||||||
'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }] }).',
|
'Execute a semantic-layer query and return headers, rows, and total row count, plus correctness notes (e.g. compile-only or fan-out) when relevant. The generated SQL and full query plan are omitted by default; request them with include: ["sql"] and/or include: ["plan"]. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }], include: ["sql"] }).',
|
||||||
sql_execution:
|
sql_execution:
|
||||||
'Execute one parser-validated read-only SQL query against a configured KTX connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).',
|
'Execute one parser-validated read-only SQL query against a configured KTX connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).',
|
||||||
memory_ingest:
|
memory_ingest:
|
||||||
|
|
@ -73,7 +74,7 @@ const connectionListSchema = z.object({});
|
||||||
|
|
||||||
const knowledgeSearchSchema = z.object({
|
const knowledgeSearchSchema = z.object({
|
||||||
query: z.string().min(1).describe('Natural-language wiki search query, e.g. "revenue recognition policy".'),
|
query: z.string().min(1).describe('Natural-language wiki search query, e.g. "revenue recognition policy".'),
|
||||||
limit: z.number().int().min(1).max(50).default(10).describe('Maximum wiki pages to return. Defaults to 10.'),
|
limit: z.number().int().min(1).max(50).default(10).describe('Maximum wiki pages to return.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const knowledgeReadSchema = z.object({
|
const knowledgeReadSchema = z.object({
|
||||||
|
|
@ -109,10 +110,7 @@ const slQueryOrderBySchema = z.object({
|
||||||
.describe(
|
.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.',
|
'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
|
direction: z.enum(['asc', 'desc']).default('asc').describe('Sort direction for this field.'),
|
||||||
.enum(['asc', 'desc'])
|
|
||||||
.default('asc')
|
|
||||||
.describe('Sort direction: "asc" or "desc". Defaults to "asc".'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const slQuerySchema = z.object({
|
const slQuerySchema = z.object({
|
||||||
|
|
@ -136,8 +134,12 @@ const slQuerySchema = z.object({
|
||||||
.array(slQueryOrderBySchema)
|
.array(slQueryOrderBySchema)
|
||||||
.default([])
|
.default([])
|
||||||
.describe('Sort clauses. Use {field, direction?} entries.'),
|
.describe('Sort clauses. Use {field, direction?} entries.'),
|
||||||
limit: z.number().int().min(0).default(1000).describe('Maximum rows to return. Defaults to 1000.'),
|
limit: z.number().int().min(0).default(1000).describe('Maximum rows to return.'),
|
||||||
include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'),
|
include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups.'),
|
||||||
|
include: z
|
||||||
|
.array(z.enum(['plan', 'sql']))
|
||||||
|
.default([])
|
||||||
|
.describe('Extra detail to attach to the response: "sql" for the generated SQL, "plan" for the full query plan.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const entityDetailsTableRefSchema = z.object({
|
const entityDetailsTableRefSchema = z.object({
|
||||||
|
|
@ -184,13 +186,13 @@ const discoverDataSchema = z.object({
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Optional connection id. Pass it when user intent pins a specific warehouse.'),
|
.describe('Optional connection id. Pass it when user intent pins a specific warehouse.'),
|
||||||
kinds: z.array(discoverDataKindSchema.describe('Reference kind to include.')).optional().describe('Optional kind filter.'),
|
kinds: z.array(discoverDataKindSchema.describe('Reference kind to include.')).optional().describe('Optional kind filter.'),
|
||||||
limit: z.number().int().min(1).max(50).default(15).optional().describe('Maximum refs to return. Defaults to 15.'),
|
limit: z.number().int().min(1).max(50).default(10).optional().describe('Maximum refs to return.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sqlExecutionSchema = z.object({
|
const sqlExecutionSchema = z.object({
|
||||||
connectionId: connectionIdSchema.describe('Connection id to execute against. Required for raw SQL.'),
|
connectionId: connectionIdSchema.describe('Connection id to execute against. Required for raw SQL.'),
|
||||||
sql: z.string().min(1).describe('Parser-validated read-only SQL, e.g. "select count(*) from public.orders".'),
|
sql: z.string().min(1).describe('Parser-validated read-only SQL, e.g. "select count(*) from public.orders".'),
|
||||||
maxRows: z.number().int().min(1).max(10_000).default(1000).optional().describe('Maximum rows to return. Defaults to 1000.'),
|
maxRows: z.number().int().min(1).max(10_000).default(1000).optional().describe('Maximum rows to return.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const memoryIngestSchema = z.object({
|
const memoryIngestSchema = z.object({
|
||||||
|
|
@ -266,10 +268,14 @@ const slReadSourceOutputSchema = z.object({
|
||||||
const slQueryOutputSchema = z.object({
|
const slQueryOutputSchema = z.object({
|
||||||
connectionId: z.string().optional(),
|
connectionId: z.string().optional(),
|
||||||
dialect: z.string().optional(),
|
dialect: z.string().optional(),
|
||||||
sql: z.string(),
|
|
||||||
headers: z.array(z.string()),
|
headers: z.array(z.string()),
|
||||||
rows: z.array(z.array(z.unknown())),
|
rows: z.array(z.array(z.unknown())),
|
||||||
totalRows: z.number(),
|
totalRows: z.number(),
|
||||||
|
// Correctness signals hoisted out of `plan` so they survive default projection (e.g. compile-only
|
||||||
|
// status, fan-out warnings). Present only when there is something to report.
|
||||||
|
notes: z.array(z.string()).optional(),
|
||||||
|
// Opt-in detail, attached only when requested via the `include` input.
|
||||||
|
sql: z.string().optional(),
|
||||||
plan: unknownRecordSchema.optional(),
|
plan: unknownRecordSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -411,12 +417,59 @@ const memoryIngestStatusOutputSchema = z.object({
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function jsonToolResult<T extends NonArrayObject>(structuredContent: T): KtxMcpToolResult<T> {
|
export function jsonToolResult<T extends NonArrayObject>(structuredContent: T): KtxMcpToolResult<T> {
|
||||||
|
// Compact (non-indented) JSON: this `content` text is the copy the model reads. Pretty-printing
|
||||||
|
// arrays-of-arrays (every `rows` payload) puts one scalar per line, inflating tabular results by
|
||||||
|
// a large constant factor. `structuredContent` carries the same data for structured-output clients.
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
|
content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the correctness-critical signals out of a query plan so they survive even when the caller
|
||||||
|
* did not opt into the full `plan`. Returns an empty list when there is nothing to flag.
|
||||||
|
*/
|
||||||
|
function slQueryNotes(plan: Record<string, unknown> | undefined): string[] {
|
||||||
|
if (!plan) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const notes: string[] = [];
|
||||||
|
const execution = plan.execution;
|
||||||
|
if (
|
||||||
|
execution &&
|
||||||
|
typeof execution === 'object' &&
|
||||||
|
(execution as Record<string, unknown>).mode === 'compile_only'
|
||||||
|
) {
|
||||||
|
const reason = (execution as Record<string, unknown>).reason;
|
||||||
|
notes.push(typeof reason === 'string' ? reason : 'Compiled SQL only; no rows were executed.');
|
||||||
|
}
|
||||||
|
if (plan.has_fan_out === true) {
|
||||||
|
const description = typeof plan.fan_out_description === 'string' ? plan.fan_out_description.trim() : '';
|
||||||
|
notes.push(description.length > 0 ? description : 'Fan-out detected: measure totals may be inflated by joins.');
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default sl_query response is the minimum the agent needs to read the result: connection, headers,
|
||||||
|
* rows, totals, plus any correctness notes. The generated `sql` and the full `plan` are attached only
|
||||||
|
* when explicitly requested via `include`, since both are large and echo information the caller already has.
|
||||||
|
*/
|
||||||
|
function projectSlQueryResult(result: KtxSemanticLayerQueryResponse, include: ('plan' | 'sql')[]) {
|
||||||
|
const notes = slQueryNotes(result.plan);
|
||||||
|
return {
|
||||||
|
...(result.connectionId !== undefined ? { connectionId: result.connectionId } : {}),
|
||||||
|
...(result.dialect !== undefined ? { dialect: result.dialect } : {}),
|
||||||
|
headers: result.headers,
|
||||||
|
rows: result.rows,
|
||||||
|
totalRows: result.totalRows,
|
||||||
|
...(notes.length > 0 ? { notes } : {}),
|
||||||
|
...(include.includes('sql') ? { sql: result.sql } : {}),
|
||||||
|
...(include.includes('plan') && result.plan ? { plan: result.plan } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function jsonErrorToolResult(text: string): KtxMcpToolResult<Record<string, never>> {
|
function jsonErrorToolResult(text: string): KtxMcpToolResult<Record<string, never>> {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text }],
|
content: [{ type: 'text', text }],
|
||||||
|
|
@ -618,23 +671,22 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
|
||||||
slQuerySchema,
|
slQuerySchema,
|
||||||
async (input, context) => {
|
async (input, context) => {
|
||||||
const onProgress = mcpProgressCallback(context);
|
const onProgress = mcpProgressCallback(context);
|
||||||
return jsonToolResult(
|
const result = await semanticLayer.query(
|
||||||
await semanticLayer.query(
|
{
|
||||||
{
|
connectionId: input.connectionId,
|
||||||
connectionId: input.connectionId,
|
query: {
|
||||||
query: {
|
measures: input.measures,
|
||||||
measures: input.measures,
|
dimensions: input.dimensions,
|
||||||
dimensions: input.dimensions,
|
filters: input.filters,
|
||||||
filters: input.filters,
|
segments: input.segments,
|
||||||
segments: input.segments,
|
order_by: input.order_by,
|
||||||
order_by: input.order_by,
|
limit: input.limit,
|
||||||
limit: input.limit,
|
include_empty: input.include_empty,
|
||||||
include_empty: input.include_empty,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
onProgress ? { onProgress } : undefined,
|
},
|
||||||
),
|
onProgress ? { onProgress } : undefined,
|
||||||
);
|
);
|
||||||
|
return jsonToolResult(projectSlQueryResult(result, input.include));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,10 @@ interface KtxSemanticLayerReadResponse {
|
||||||
yaml: string;
|
yaml: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KtxSemanticLayerQueryResponse {
|
/** @internal */
|
||||||
|
export interface KtxSemanticLayerQueryResponse {
|
||||||
|
connectionId?: string;
|
||||||
|
dialect?: string;
|
||||||
sql: string;
|
sql: string;
|
||||||
headers: string[];
|
headers: string[];
|
||||||
rows: unknown[][];
|
rows: unknown[][];
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ async function wikiCandidates(
|
||||||
query: input.query,
|
query: input.query,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
embeddingService: options.embeddingService ?? null,
|
embeddingService: options.embeddingService ?? null,
|
||||||
limit: Math.max(input.limit ?? 15, 25),
|
limit: Math.max(input.limit ?? 10, 25),
|
||||||
});
|
});
|
||||||
const records: CandidateRecord[] = [];
|
const records: CandidateRecord[] = [];
|
||||||
for (const result of searchResults) {
|
for (const result of searchResults) {
|
||||||
|
|
@ -421,7 +421,8 @@ function hydrate(
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...ref,
|
...ref,
|
||||||
score: maxScore > 0 ? Number((candidate.score / maxScore).toFixed(6)) : 0,
|
// 3 decimals is plenty for a relative-rank hint; 6 just spent bytes on noise.
|
||||||
|
score: maxScore > 0 ? Number((candidate.score / maxScore).toFixed(3)) : 0,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((result): result is KtxDiscoverDataRef => result !== null);
|
.filter((result): result is KtxDiscoverDataRef => result !== null);
|
||||||
|
|
@ -433,7 +434,7 @@ export function createKtxDiscoverDataService(
|
||||||
): { search(input: KtxDiscoverDataInput): Promise<KtxDiscoverDataResponse> } {
|
): { search(input: KtxDiscoverDataInput): Promise<KtxDiscoverDataResponse> } {
|
||||||
return {
|
return {
|
||||||
async search(input) {
|
async search(input) {
|
||||||
const limit = Math.max(1, Math.min(input.limit ?? 15, 50));
|
const limit = Math.max(1, Math.min(input.limit ?? 10, 50));
|
||||||
const query = input.query.trim();
|
const query = input.query.trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,12 @@ You have access to KTX MCP tools for data discovery, semantic-layer analysis, ra
|
||||||
- Read entity details before writing SQL against an unfamiliar table. Do not assume column names.
|
- Read entity details before writing SQL against an unfamiliar table. Do not assume column names.
|
||||||
- Treat `sql_execution` as read-only. Writes are rejected by the server.
|
- Treat `sql_execution` as read-only. Writes are rejected by the server.
|
||||||
- Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent.
|
- Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent.
|
||||||
- When `connection_list` shows multiple connections, pass an explicit `connectionId` to every tool that takes one and where user intent pins a specific warehouse. Required: `entity_details`, `sl_read_source`, and `sql_execution`. Required when user intent is warehouse-specific, including wording like "in our warehouse" or "this warehouse": `memory_ingest`; without `connectionId`, the memory agent cannot update the semantic layer and the knowledge lands as wiki-only. Pass `connectionId` when intent pins a warehouse, otherwise omit for unscoped discovery: `sl_query`, `discover_data`, and `dictionary_search`. Never pass `connectionId` to `connection_list`, `wiki_search`, `wiki_read`, or `memory_ingest_status`. If intent is ambiguous for a required-or-scoped tool, ask the user which warehouse before calling.
|
- `connectionId` scoping when `connection_list` shows multiple connections:
|
||||||
|
- Always pass it: `entity_details`, `sl_read_source`, `sql_execution`.
|
||||||
|
- Pass it when intent pins a warehouse, otherwise omit for unscoped discovery: `sl_query`, `discover_data`, `dictionary_search`.
|
||||||
|
- `memory_ingest`: pass it for warehouse-specific knowledge (e.g. "in our warehouse"); without it the memory lands as wiki-only and cannot update the semantic layer.
|
||||||
|
- Never pass it: `connection_list`, `wiki_search`, `wiki_read`, `memory_ingest_status`.
|
||||||
|
- If scoping is required but intent is ambiguous, ask which warehouse before calling.
|
||||||
- Show compact result tables for small outputs. For broad results, summarize the top findings and mention the applied limit.
|
- Show compact result tables for small outputs. For broad results, summarize the top findings and mention the applied limit.
|
||||||
- Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context.
|
- Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context.
|
||||||
</rules>
|
</rules>
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
"default": 10,
|
"default": 10,
|
||||||
"description": "Maximum wiki pages to return. Defaults to 10.",
|
"description": "Maximum wiki pages to return.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 50
|
"maximum": 50
|
||||||
|
|
@ -307,7 +307,7 @@
|
||||||
{
|
{
|
||||||
"name": "sl_query",
|
"name": "sl_query",
|
||||||
"title": "Semantic Layer Query",
|
"title": "Semantic Layer Query",
|
||||||
"description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).",
|
"description": "Execute a semantic-layer query and return headers, rows, and total row count, plus correctness notes (e.g. compile-only or fan-out) when relevant. The generated SQL and full query plan are omitted by default; request them with include: [\"sql\"] and/or include: [\"plan\"]. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }], include: [\"sql\"] }).",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -403,7 +403,7 @@
|
||||||
},
|
},
|
||||||
"direction": {
|
"direction": {
|
||||||
"default": "asc",
|
"default": "asc",
|
||||||
"description": "Sort direction: \"asc\" or \"desc\". Defaults to \"asc\".",
|
"description": "Sort direction for this field.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"asc",
|
"asc",
|
||||||
|
|
@ -418,15 +418,27 @@
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
"default": 1000,
|
"default": 1000,
|
||||||
"description": "Maximum rows to return. Defaults to 1000.",
|
"description": "Maximum rows to return.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
},
|
},
|
||||||
"include_empty": {
|
"include_empty": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Whether to include empty dimension groups. Defaults to true.",
|
"description": "Whether to include empty dimension groups.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"include": {
|
||||||
|
"default": [],
|
||||||
|
"description": "Extra detail to attach to the response: \"sql\" for the generated SQL, \"plan\" for the full query plan.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"plan",
|
||||||
|
"sql"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -443,9 +455,6 @@
|
||||||
"dialect": {
|
"dialect": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"sql": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"headers": {
|
"headers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|
@ -462,6 +471,15 @@
|
||||||
"totalRows": {
|
"totalRows": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sql": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"plan": {
|
"plan": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
|
|
@ -471,7 +489,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"sql",
|
|
||||||
"headers",
|
"headers",
|
||||||
"rows",
|
"rows",
|
||||||
"totalRows"
|
"totalRows"
|
||||||
|
|
@ -1241,8 +1258,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
"description": "Maximum refs to return. Defaults to 15.",
|
"description": "Maximum refs to return.",
|
||||||
"default": 15,
|
"default": 10,
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 50
|
"maximum": 50
|
||||||
|
|
@ -1396,7 +1413,7 @@
|
||||||
"description": "Parser-validated read-only SQL, e.g. \"select count(*) from public.orders\"."
|
"description": "Parser-validated read-only SQL, e.g. \"select count(*) from public.orders\"."
|
||||||
},
|
},
|
||||||
"maxRows": {
|
"maxRows": {
|
||||||
"description": "Maximum rows to return. Defaults to 1000.",
|
"description": "Maximum rows to return.",
|
||||||
"default": 1000,
|
"default": 1000,
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
|
|
|
||||||
|
|
@ -307,16 +307,12 @@ describe('createKtxMcpServer', () => {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify(
|
text: JSON.stringify({
|
||||||
{
|
headers: ['status', 'count'],
|
||||||
headers: ['status', 'count'],
|
headerTypes: ['text', 'bigint'],
|
||||||
headerTypes: ['text', 'bigint'],
|
rows: [['paid', 42]],
|
||||||
rows: [['paid', 42]],
|
rowCount: 1,
|
||||||
rowCount: 1,
|
}),
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
|
|
@ -598,6 +594,92 @@ describe('createKtxMcpServer', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sl_query default response omits plan and sql but keeps compile-only and fan-out notes', async () => {
|
||||||
|
const fake = makeFakeServer();
|
||||||
|
const semanticLayer: KtxSemanticLayerMcpPort = {
|
||||||
|
readSource: vi.fn(),
|
||||||
|
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
dialect: 'postgres',
|
||||||
|
sql: 'select count(*) from public.orders',
|
||||||
|
headers: ['order_count'],
|
||||||
|
rows: [],
|
||||||
|
totalRows: 0,
|
||||||
|
plan: {
|
||||||
|
sources_used: ['orders'],
|
||||||
|
has_fan_out: true,
|
||||||
|
fan_out_description: 'orders fans out across line_items',
|
||||||
|
execution: { mode: 'compile_only', reason: 'No execution adapter configured.' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
createKtxMcpServer({
|
||||||
|
server: fake.server,
|
||||||
|
userContext: { userId: 'local-user' },
|
||||||
|
contextTools: { semanticLayer },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getTool(fake.tools, 'sl_query').handler({
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
measures: ['orders.order_count'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
structuredContent: {
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
dialect: 'postgres',
|
||||||
|
headers: ['order_count'],
|
||||||
|
rows: [],
|
||||||
|
totalRows: 0,
|
||||||
|
notes: ['No execution adapter configured.', 'orders fans out across line_items'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const structured = (result as { structuredContent: Record<string, unknown> }).structuredContent;
|
||||||
|
expect(structured.sql).toBeUndefined();
|
||||||
|
expect(structured.plan).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sl_query attaches sql and plan only when include requests them', async () => {
|
||||||
|
const fake = makeFakeServer();
|
||||||
|
const plan = { sources_used: ['orders'], execution: { mode: 'executed' } };
|
||||||
|
const semanticLayer: KtxSemanticLayerMcpPort = {
|
||||||
|
readSource: vi.fn(),
|
||||||
|
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
dialect: 'postgres',
|
||||||
|
sql: 'select count(*) from public.orders',
|
||||||
|
headers: ['order_count'],
|
||||||
|
rows: [[3]],
|
||||||
|
totalRows: 1,
|
||||||
|
plan,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
createKtxMcpServer({
|
||||||
|
server: fake.server,
|
||||||
|
userContext: { userId: 'local-user' },
|
||||||
|
contextTools: { semanticLayer },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getTool(fake.tools, 'sl_query').handler({
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
measures: ['orders.order_count'],
|
||||||
|
include: ['plan', 'sql'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
structuredContent: {
|
||||||
|
sql: 'select count(*) from public.orders',
|
||||||
|
plan,
|
||||||
|
rows: [[3]],
|
||||||
|
totalRows: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const structured = (result as { structuredContent: Record<string, unknown> }).structuredContent;
|
||||||
|
expect(structured.notes).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('entity_details rejects sql-style schema table ref aliases', async () => {
|
it('entity_details rejects sql-style schema table ref aliases', async () => {
|
||||||
const fake = makeFakeServer();
|
const fake = makeFakeServer();
|
||||||
const entityDetails = makeAllContextTools().entityDetails!;
|
const entityDetails = makeAllContextTools().entityDetails!;
|
||||||
|
|
@ -798,7 +880,7 @@ describe('createKtxMcpServer', () => {
|
||||||
connectionId: '00000000-0000-4000-8000-000000000001',
|
connectionId: '00000000-0000-4000-8000-000000000001',
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
content: [{ type: 'text', text: JSON.stringify({ runId: 'run-1' }, null, 2) }],
|
content: [{ type: 'text', text: JSON.stringify({ runId: 'run-1' }) }],
|
||||||
structuredContent: { runId: 'run-1' },
|
structuredContent: { runId: 'run-1' },
|
||||||
});
|
});
|
||||||
expect(ingest.ingest).toHaveBeenCalledWith({
|
expect(ingest.ingest).toHaveBeenCalledWith({
|
||||||
|
|
@ -825,21 +907,17 @@ describe('createKtxMcpServer', () => {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify(
|
text: JSON.stringify({
|
||||||
{
|
runId: 'run-1',
|
||||||
runId: 'run-1',
|
status: 'done',
|
||||||
status: 'done',
|
stage: 'done',
|
||||||
stage: 'done',
|
done: true,
|
||||||
done: true,
|
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
|
||||||
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
|
error: null,
|
||||||
error: null,
|
commitHash: 'abc123',
|
||||||
commitHash: 'abc123',
|
skillsLoaded: ['wiki_capture'],
|
||||||
skillsLoaded: ['wiki_capture'],
|
signalDetected: true,
|
||||||
signalDetected: true,
|
}),
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
|
|
@ -1047,19 +1125,15 @@ describe('createKtxMcpServer', () => {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify(
|
text: JSON.stringify({
|
||||||
{
|
connections: [
|
||||||
connections: [
|
{
|
||||||
{
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
id: '00000000-0000-4000-8000-000000000001',
|
name: 'Warehouse',
|
||||||
name: 'Warehouse',
|
connectionType: 'POSTGRES',
|
||||||
connectionType: 'POSTGRES',
|
},
|
||||||
},
|
],
|
||||||
],
|
}),
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue