feat: trim MCP query response payloads

This commit is contained in:
Andrey Avtomonov 2026-05-30 16:21:21 +02:00
parent 8ebc4ce107
commit 133a2f700a
7 changed files with 235 additions and 1703 deletions

View file

@ -65,7 +65,7 @@
},
"limit": {
"default": 10,
"description": "Maximum wiki pages to return. Defaults to 10.",
"description": "Maximum wiki pages to return.",
"type": "integer",
"minimum": 1,
"maximum": 50
@ -307,7 +307,7 @@
{
"name": "sl_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": {
"type": "object",
"properties": {
@ -403,7 +403,7 @@
},
"direction": {
"default": "asc",
"description": "Sort direction: \"asc\" or \"desc\". Defaults to \"asc\".",
"description": "Sort direction for this field.",
"type": "string",
"enum": [
"asc",
@ -418,15 +418,27 @@
},
"limit": {
"default": 1000,
"description": "Maximum rows to return. Defaults to 1000.",
"description": "Maximum rows to return.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"include_empty": {
"default": true,
"description": "Whether to include empty dimension groups. Defaults to true.",
"description": "Whether to include empty dimension groups.",
"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": [
@ -443,9 +455,6 @@
"dialect": {
"type": "string"
},
"sql": {
"type": "string"
},
"headers": {
"type": "array",
"items": {
@ -462,6 +471,15 @@
"totalRows": {
"type": "number"
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
},
"sql": {
"type": "string"
},
"plan": {
"type": "object",
"propertyNames": {
@ -471,7 +489,6 @@
}
},
"required": [
"sql",
"headers",
"rows",
"totalRows"
@ -1241,8 +1258,8 @@
}
},
"limit": {
"description": "Maximum refs to return. Defaults to 15.",
"default": 15,
"description": "Maximum refs to return.",
"default": 10,
"type": "integer",
"minimum": 1,
"maximum": 50
@ -1396,7 +1413,7 @@
"description": "Parser-validated read-only SQL, e.g. \"select count(*) from public.orders\"."
},
"maxRows": {
"description": "Maximum rows to return. Defaults to 1000.",
"description": "Maximum rows to return.",
"default": 1000,
"type": "integer",
"minimum": 1,

View file

@ -307,16 +307,12 @@ describe('createKtxMcpServer', () => {
content: [
{
type: 'text',
text: JSON.stringify(
{
headers: ['status', 'count'],
headerTypes: ['text', 'bigint'],
rows: [['paid', 42]],
rowCount: 1,
},
null,
2,
),
text: JSON.stringify({
headers: ['status', 'count'],
headerTypes: ['text', 'bigint'],
rows: [['paid', 42]],
rowCount: 1,
}),
},
],
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 () => {
const fake = makeFakeServer();
const entityDetails = makeAllContextTools().entityDetails!;
@ -798,7 +880,7 @@ describe('createKtxMcpServer', () => {
connectionId: '00000000-0000-4000-8000-000000000001',
}),
).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' },
});
expect(ingest.ingest).toHaveBeenCalledWith({
@ -825,21 +907,17 @@ describe('createKtxMcpServer', () => {
content: [
{
type: 'text',
text: JSON.stringify(
{
runId: 'run-1',
status: 'done',
stage: 'done',
done: true,
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
error: null,
commitHash: 'abc123',
skillsLoaded: ['wiki_capture'],
signalDetected: true,
},
null,
2,
),
text: JSON.stringify({
runId: 'run-1',
status: 'done',
stage: 'done',
done: true,
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
error: null,
commitHash: 'abc123',
skillsLoaded: ['wiki_capture'],
signalDetected: true,
}),
},
],
structuredContent: {
@ -1047,19 +1125,15 @@ describe('createKtxMcpServer', () => {
content: [
{
type: 'text',
text: JSON.stringify(
{
connections: [
{
id: '00000000-0000-4000-8000-000000000001',
name: 'Warehouse',
connectionType: 'POSTGRES',
},
],
},
null,
2,
),
text: JSON.stringify({
connections: [
{
id: '00000000-0000-4000-8000-000000000001',
name: 'Warehouse',
connectionType: 'POSTGRES',
},
],
}),
},
],
structuredContent: {