mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
* docs(specs): design research-agent MCP tools and ktx mcp daemon Adds the 2026-05-14 design spec for exposing four new MCP tools (discover_data, entity_details, dictionary_search, sql_execution), shipping a ktx-research skill, and introducing an HTTP-only ktx mcp daemon so external agents can use KTX as a research-capable context layer. * Refine research-agent MCP tools spec after adversarial review iteration 1 * Refine research-agent MCP tools spec after adversarial review iteration 2 * Refine research-agent MCP tools spec after adversarial review iteration 3 * Refine spec: drop connectionName compat carve-out and ground summary/snippet provenance per kind * feat(daemon): validate read-only SQL with sqlglot * feat(context): expose read-only SQL validation port * feat(context): register MCP sql execution tool * feat(context): execute MCP SQL through validated connector path * test(context): update SQL analysis port fixtures * docs: add research-agent MCP sql execution foundation plan * feat(context): add scan-backed entity details service * feat(context): register MCP entity details tool * feat(context): expose local MCP entity details * test(context): align entity details scan fixtures * docs: add research-agent MCP entity_details plan * feat(context): add dictionary search service * feat(context): register MCP dictionary search tool * feat(context): expose local MCP dictionary search * docs: add research-agent MCP dictionary_search plan * feat: add MCP discover data service * feat: expose discover data MCP tool * feat: wire local discover data MCP port * docs: add research-agent MCP discover_data plan * feat(cli): add mcp http security helpers * feat(cli): host mcp over streamable http * feat(cli): manage mcp daemon lifecycle * feat(cli): add ktx mcp commands * fix(cli): stabilize mcp daemon verification * docs: add research-agent MCP http daemon plan * feat(cli): install KTX research skill * feat(cli): configure MCP clients in setup agents * feat(cli): support Claude local MCP setup scope * docs: add research-agent MCP setup-agents plan * refactor(context): use connectionId in warehouse verification tools * docs(context): update ingest verification prompts for connectionId * docs: add research-agent MCP ingest contract convergence plan * chore: build runtime artifacts in conductor setup --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { z } from 'zod';
|
|
import { assertReadOnlySql, limitSqlForExecution } from '../../../connections/index.js';
|
|
import type { SlConnectionCatalogPort } from '../../../sl/index.js';
|
|
import { BaseTool, type ToolContext, type ToolOutput } from '../../../tools/index.js';
|
|
|
|
const sqlExecutionInputSchema = z.object({
|
|
connectionId: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/),
|
|
sql: z.string().min(1),
|
|
rowLimit: z.number().int().positive().max(1000).optional().default(100),
|
|
}).strict();
|
|
|
|
type SqlExecutionInput = z.input<typeof sqlExecutionInputSchema>;
|
|
|
|
export interface SqlExecutionStructured {
|
|
headers: string[];
|
|
rows: unknown[][];
|
|
rowCount: number;
|
|
truncated: boolean;
|
|
sql: string;
|
|
wrappedSql: string;
|
|
error?: string;
|
|
}
|
|
|
|
function markdownTable(headers: string[], rows: unknown[][], totalRows: number): string {
|
|
if (headers.length === 0) {
|
|
return rows.length === 0 ? 'Query returned no rows.' : JSON.stringify(rows.slice(0, 20));
|
|
}
|
|
const visible = rows.slice(0, 20);
|
|
const lines = [
|
|
`| ${headers.join(' | ')} |`,
|
|
`| ${headers.map(() => '---').join(' | ')} |`,
|
|
...visible.map((row) => `| ${row.map((value) => String(value ?? '')).join(' | ')} |`),
|
|
];
|
|
if (totalRows > visible.length) {
|
|
lines.push(`... +${totalRows - visible.length} more rows`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export class SqlExecutionTool extends BaseTool<typeof sqlExecutionInputSchema> {
|
|
readonly name = 'sql_execution';
|
|
|
|
constructor(private readonly connections: SlConnectionCatalogPort) {
|
|
super();
|
|
}
|
|
|
|
get description(): string {
|
|
return 'Run a single read-only SELECT or WITH probe against an allowed warehouse connection and return a capped markdown table or the warehouse error.';
|
|
}
|
|
|
|
get inputSchema() {
|
|
return sqlExecutionInputSchema;
|
|
}
|
|
|
|
async call(input: SqlExecutionInput, context: ToolContext): Promise<ToolOutput<SqlExecutionStructured>> {
|
|
const allowed = context.session?.allowedConnectionNames;
|
|
if (allowed && !allowed.has(input.connectionId)) {
|
|
return {
|
|
markdown: `Connection "${input.connectionId}" is not available to this ingest stage.`,
|
|
structured: {
|
|
headers: [],
|
|
rows: [],
|
|
rowCount: 0,
|
|
truncated: false,
|
|
sql: input.sql,
|
|
wrappedSql: '',
|
|
error: 'connection_not_allowed',
|
|
},
|
|
};
|
|
}
|
|
|
|
let sql: string;
|
|
let wrappedSql: string;
|
|
try {
|
|
sql = assertReadOnlySql(input.sql);
|
|
wrappedSql = limitSqlForExecution(sql, input.rowLimit);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
markdown: message,
|
|
structured: { headers: [], rows: [], rowCount: 0, truncated: false, sql: input.sql, wrappedSql: '', error: message },
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await this.connections.executeQuery(input.connectionId, wrappedSql);
|
|
const headers = result.headers ?? [];
|
|
const rows = result.rows ?? [];
|
|
const rowCount = result.totalRows ?? rows.length;
|
|
return {
|
|
markdown: markdownTable(headers, rows, rowCount),
|
|
structured: { headers, rows, rowCount, truncated: rowCount > rows.length, sql, wrappedSql },
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
markdown: `SQL execution failed: ${message}`,
|
|
structured: { headers: [], rows: [], rowCount: 0, truncated: false, sql, wrappedSql, error: message },
|
|
};
|
|
}
|
|
}
|
|
}
|