@kaelio/ktx 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/assets/python/{kaelio_ktx-0.12.0-py3-none-any.whl → kaelio_ktx-0.13.1-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/commands/setup-commands.js +13 -0
  5. package/dist/connection.js +14 -2
  6. package/dist/connectors/bigquery/connector.js +1 -14
  7. package/dist/connectors/clickhouse/connector.js +1 -15
  8. package/dist/connectors/duckdb/federated-attach.d.ts +7 -0
  9. package/dist/connectors/duckdb/federated-attach.js +86 -0
  10. package/dist/connectors/duckdb/federated-executor.d.ts +5 -0
  11. package/dist/connectors/duckdb/federated-executor.js +59 -0
  12. package/dist/connectors/mysql/connector.js +1 -15
  13. package/dist/connectors/postgres/connector.js +1 -14
  14. package/dist/connectors/shared/string-reference.d.ts +6 -0
  15. package/dist/connectors/shared/string-reference.js +19 -0
  16. package/dist/connectors/snowflake/connector.js +1 -14
  17. package/dist/connectors/sqlserver/connector.js +4 -16
  18. package/dist/context/connections/federation.d.ts +33 -0
  19. package/dist/context/connections/federation.js +51 -0
  20. package/dist/context/connections/local-warehouse-descriptor.d.ts +2 -0
  21. package/dist/context/connections/project-sql-executor.d.ts +18 -0
  22. package/dist/context/connections/project-sql-executor.js +39 -0
  23. package/dist/context/connections/query-executor.d.ts +2 -2
  24. package/dist/context/connections/read-only-sql.d.ts +5 -0
  25. package/dist/context/connections/read-only-sql.js +143 -4
  26. package/dist/context/connections/resolve-connection.d.ts +12 -0
  27. package/dist/context/connections/resolve-connection.js +37 -0
  28. package/dist/context/core/git-env.d.ts +4 -0
  29. package/dist/context/core/git-env.js +5 -1
  30. package/dist/context/ingest/adapters/live-database/manifest.d.ts +3 -0
  31. package/dist/context/ingest/adapters/live-database/manifest.js +19 -11
  32. package/dist/context/llm/claude-code-runtime.js +18 -2
  33. package/dist/context/mcp/context-tools.js +27 -2
  34. package/dist/context/mcp/local-project-ports.js +55 -50
  35. package/dist/context/mcp/types.d.ts +2 -0
  36. package/dist/context/scan/local-enrichment-artifacts.js +31 -3
  37. package/dist/context/sl/local-query.js +29 -12
  38. package/dist/context/sl/local-sl.js +27 -1
  39. package/dist/context/sl/source-files.d.ts +2 -0
  40. package/dist/context/sl/source-files.js +7 -0
  41. package/dist/ingest-query-executor.d.ts +2 -0
  42. package/dist/ingest-query-executor.js +8 -22
  43. package/dist/setup-agents.d.ts +21 -15
  44. package/dist/setup-agents.js +128 -42
  45. package/dist/setup-databases.d.ts +3 -0
  46. package/dist/setup-databases.js +16 -0
  47. package/dist/setup-sources.js +1 -5
  48. package/dist/setup.d.ts +1 -0
  49. package/dist/setup.js +1 -0
  50. package/dist/sql.d.ts +2 -0
  51. package/dist/sql.js +35 -53
  52. package/dist/telemetry/events.d.ts +2 -1
  53. package/dist/telemetry/events.js +11 -1
  54. package/package.json +2 -1
@@ -1,3 +1,4 @@
1
+ import { KtxQueryError } from '../../errors.js';
1
2
  const MUTATING_SQL = /^\s*(insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|copy|call|do|vacuum|analyze|refresh)\b/i;
2
3
  const READ_SQL = /^\s*(select|with)\b/i;
3
4
  // Agents (and the daemon's sqlglot validator, which ignores comments) routinely
@@ -77,7 +78,7 @@ function assertSingleSqlStatement(sql) {
77
78
  sawSemicolon = true;
78
79
  }
79
80
  else if (sawSemicolon && !/\s/.test(sql[index])) {
80
- throw new Error('Only one SQL statement can be executed.');
81
+ throw new KtxQueryError('Only one SQL statement can be executed.');
81
82
  }
82
83
  index += 1;
83
84
  }
@@ -85,11 +86,148 @@ function assertSingleSqlStatement(sql) {
85
86
  export function assertReadOnlySql(sql) {
86
87
  const trimmed = stripLeadingSqlComments(sql).trim();
87
88
  if (!READ_SQL.test(trimmed) || MUTATING_SQL.test(trimmed)) {
88
- throw new Error('Only read-only SELECT/WITH queries can be executed locally.');
89
+ throw new KtxQueryError('Only read-only SELECT/WITH queries can be executed locally.');
89
90
  }
90
91
  assertSingleSqlStatement(trimmed);
91
92
  return trimmed;
92
93
  }
94
+ function isSqlIdentifierPart(char) {
95
+ return char !== undefined && /[A-Za-z0-9_$]/.test(char);
96
+ }
97
+ function keywordAt(sql, index, keyword) {
98
+ if (sql.slice(index, index + keyword.length).toLowerCase() !== keyword.toLowerCase()) {
99
+ return false;
100
+ }
101
+ return !isSqlIdentifierPart(sql[index - 1]) && !isSqlIdentifierPart(sql[index + keyword.length]);
102
+ }
103
+ function skipWhitespaceAndComments(sql, index) {
104
+ let current = index;
105
+ while (current < sql.length) {
106
+ while (/\s/.test(sql[current] ?? '')) {
107
+ current += 1;
108
+ }
109
+ if (sql.startsWith('--', current) || sql.startsWith('/*', current)) {
110
+ current = skipQuotedOrComment(sql, current);
111
+ continue;
112
+ }
113
+ return current;
114
+ }
115
+ return current;
116
+ }
117
+ function skipBracketIdentifier(sql, index) {
118
+ let current = index + 1;
119
+ while (current < sql.length) {
120
+ if (sql[current] === ']') {
121
+ if (sql[current + 1] === ']') {
122
+ current += 2;
123
+ continue;
124
+ }
125
+ return current + 1;
126
+ }
127
+ current += 1;
128
+ }
129
+ return -1;
130
+ }
131
+ function skipBacktickIdentifier(sql, index) {
132
+ let current = index + 1;
133
+ while (current < sql.length) {
134
+ if (sql[current] === '`') {
135
+ if (sql[current + 1] === '`') {
136
+ current += 2;
137
+ continue;
138
+ }
139
+ return current + 1;
140
+ }
141
+ current += 1;
142
+ }
143
+ return -1;
144
+ }
145
+ function skipIdentifier(sql, index) {
146
+ if (sql[index] === '"') {
147
+ const skipped = skipQuotedOrComment(sql, index);
148
+ return skipped > index ? skipped : -1;
149
+ }
150
+ if (sql[index] === '[') {
151
+ return skipBracketIdentifier(sql, index);
152
+ }
153
+ if (sql[index] === '`') {
154
+ return skipBacktickIdentifier(sql, index);
155
+ }
156
+ let current = index;
157
+ while (isSqlIdentifierPart(sql[current])) {
158
+ current += 1;
159
+ }
160
+ return current > index ? current : -1;
161
+ }
162
+ function skipBalancedParentheses(sql, index) {
163
+ if (sql[index] !== '(') {
164
+ return -1;
165
+ }
166
+ let current = index;
167
+ let depth = 0;
168
+ while (current < sql.length) {
169
+ const skipped = skipQuotedOrComment(sql, current);
170
+ if (skipped > current) {
171
+ current = skipped;
172
+ continue;
173
+ }
174
+ if (sql[current] === '(') {
175
+ depth += 1;
176
+ }
177
+ else if (sql[current] === ')') {
178
+ depth -= 1;
179
+ if (depth === 0) {
180
+ return current + 1;
181
+ }
182
+ }
183
+ current += 1;
184
+ }
185
+ return -1;
186
+ }
187
+ /** @internal */
188
+ export function hoistLeadingCte(sql) {
189
+ const trimmed = sql.trim();
190
+ if (!keywordAt(trimmed, 0, 'with')) {
191
+ return { withPrefix: '', body: sql };
192
+ }
193
+ let current = skipWhitespaceAndComments(trimmed, 4);
194
+ if (keywordAt(trimmed, current, 'recursive')) {
195
+ current = skipWhitespaceAndComments(trimmed, current + 'recursive'.length);
196
+ }
197
+ while (current < trimmed.length) {
198
+ current = skipIdentifier(trimmed, current);
199
+ if (current < 0) {
200
+ return { withPrefix: '', body: trimmed };
201
+ }
202
+ current = skipWhitespaceAndComments(trimmed, current);
203
+ if (trimmed[current] === '(') {
204
+ current = skipBalancedParentheses(trimmed, current);
205
+ if (current < 0) {
206
+ return { withPrefix: '', body: trimmed };
207
+ }
208
+ current = skipWhitespaceAndComments(trimmed, current);
209
+ }
210
+ if (!keywordAt(trimmed, current, 'as')) {
211
+ return { withPrefix: '', body: trimmed };
212
+ }
213
+ current = skipWhitespaceAndComments(trimmed, current + 2);
214
+ current = skipBalancedParentheses(trimmed, current);
215
+ if (current < 0) {
216
+ return { withPrefix: '', body: trimmed };
217
+ }
218
+ current = skipWhitespaceAndComments(trimmed, current);
219
+ if (trimmed[current] === ',') {
220
+ current = skipWhitespaceAndComments(trimmed, current + 1);
221
+ continue;
222
+ }
223
+ const body = trimmed.slice(current).trimStart();
224
+ if (!body) {
225
+ return { withPrefix: '', body: trimmed };
226
+ }
227
+ return { withPrefix: `${trimmed.slice(0, current).trimEnd()} `, body };
228
+ }
229
+ return { withPrefix: '', body: trimmed };
230
+ }
93
231
  // `assertReadOnlySql` deliberately keeps trailing semicolons, comments, and
94
232
  // whitespace (e.g. `select 1; -- done`) — harmless for direct single-statement
95
233
  // execution. A row-limit subquery wrapper needs a bare expression instead: a
@@ -127,7 +265,8 @@ export function limitSqlForExecution(sql, maxRows) {
127
265
  return trimmed;
128
266
  }
129
267
  if (!Number.isInteger(maxRows) || maxRows <= 0) {
130
- throw new Error('maxRows must be a positive integer.');
268
+ throw new KtxQueryError('maxRows must be a positive integer.');
131
269
  }
132
- return `select * from (${trimmed}) as ktx_query_result limit ${maxRows}`;
270
+ const { withPrefix, body } = hoistLeadingCte(trimmed);
271
+ return `${withPrefix}select * from (${body}) as ktx_query_result limit ${maxRows}`;
133
272
  }
@@ -0,0 +1,12 @@
1
+ import type { KtxProjectConfig, KtxProjectConnectionConfig } from '../project/config.js';
2
+ /**
3
+ * Look up a connection by id, throwing an expected (caller-driven) error that
4
+ * names the configured connections so an agent or CLI user can self-correct.
5
+ */
6
+ export declare function resolveConfiguredConnection(config: KtxProjectConfig, connectionId: string): KtxProjectConnectionConfig;
7
+ /**
8
+ * Resolve the connection id to run against: validate a requested id against the
9
+ * configured connections, or default to the sole connection when none is given.
10
+ * Throws an expected error that lists the configured connections otherwise.
11
+ */
12
+ export declare function resolveRequiredConnectionId(config: KtxProjectConfig, requested: string | undefined): string;
@@ -0,0 +1,37 @@
1
+ import { KtxExpectedError } from '../../errors.js';
2
+ function configuredConnectionIds(config) {
3
+ return Object.keys(config.connections).sort();
4
+ }
5
+ function availableConnectionsHint(config) {
6
+ const ids = configuredConnectionIds(config);
7
+ return ids.length === 0
8
+ ? 'No connections are configured in ktx.yaml.'
9
+ : `Configured connections: ${ids.join(', ')}.`;
10
+ }
11
+ /**
12
+ * Look up a connection by id, throwing an expected (caller-driven) error that
13
+ * names the configured connections so an agent or CLI user can self-correct.
14
+ */
15
+ export function resolveConfiguredConnection(config, connectionId) {
16
+ const connection = config.connections[connectionId];
17
+ if (!connection) {
18
+ throw new KtxExpectedError(`Connection "${connectionId}" is not configured in ktx.yaml. ${availableConnectionsHint(config)}`);
19
+ }
20
+ return connection;
21
+ }
22
+ /**
23
+ * Resolve the connection id to run against: validate a requested id against the
24
+ * configured connections, or default to the sole connection when none is given.
25
+ * Throws an expected error that lists the configured connections otherwise.
26
+ */
27
+ export function resolveRequiredConnectionId(config, requested) {
28
+ if (requested !== undefined) {
29
+ resolveConfiguredConnection(config, requested);
30
+ return requested;
31
+ }
32
+ const ids = configuredConnectionIds(config);
33
+ if (ids.length === 1) {
34
+ return ids[0];
35
+ }
36
+ throw new KtxExpectedError(`connectionId is required. ${availableConnectionsHint(config)}`);
37
+ }
@@ -6,6 +6,10 @@ import { type SimpleGit } from 'simple-git';
6
6
  * directory is an existing repo ktx did not create and the machine has no configured git
7
7
  * identity (e.g. a fresh Mac with no ~/.gitconfig), without mutating the user's repo config.
8
8
  * Explicit `--author` flags on individual commits still take precedence over GIT_AUTHOR_NAME.
9
+ *
10
+ * `commit.gpgsign=false` is injected as a per-invocation `-c` override so ktx's commits never
11
+ * attempt GPG signing: ktx commits under a synthetic identity that can never own a secret key, so
12
+ * a user's `commit.gpgsign=true` would otherwise fail every commit with "No secret key".
9
13
  */
10
14
  export declare function createSimpleGit(baseDir: string, identity?: {
11
15
  name: string;
@@ -28,6 +28,10 @@ function sanitizedGitEnv(env = process.env) {
28
28
  * directory is an existing repo ktx did not create and the machine has no configured git
29
29
  * identity (e.g. a fresh Mac with no ~/.gitconfig), without mutating the user's repo config.
30
30
  * Explicit `--author` flags on individual commits still take precedence over GIT_AUTHOR_NAME.
31
+ *
32
+ * `commit.gpgsign=false` is injected as a per-invocation `-c` override so ktx's commits never
33
+ * attempt GPG signing: ktx commits under a synthetic identity that can never own a secret key, so
34
+ * a user's `commit.gpgsign=true` would otherwise fail every commit with "No secret key".
31
35
  */
32
36
  export function createSimpleGit(baseDir, identity) {
33
37
  const env = sanitizedGitEnv();
@@ -37,5 +41,5 @@ export function createSimpleGit(baseDir, identity) {
37
41
  env.GIT_COMMITTER_NAME = identity.name;
38
42
  env.GIT_COMMITTER_EMAIL = identity.email;
39
43
  }
40
- return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(env);
44
+ return simpleGit({ baseDir, config: ['commit.gpgsign=false'], unsafe: { allowUnsafeAskPass: true } }).env(env);
41
45
  }
@@ -56,11 +56,14 @@ export interface BuildLiveDatabaseManifestShardsInput {
56
56
  existingPreservedJoins?: Map<string, LiveDatabaseManifestJoinEntry[]>;
57
57
  existingDescriptions?: Map<string, LiveDatabaseManifestExistingDescriptions>;
58
58
  existingUsage?: Map<string, TableUsageOutput>;
59
+ federatedSiblingTargets?: Set<string>;
59
60
  }
60
61
  export interface BuildLiveDatabaseManifestShardsResult {
61
62
  shards: Map<string, LiveDatabaseManifestShard>;
62
63
  tablesProcessed: number;
63
64
  }
64
65
  export declare function mergeUsagePreservingExternal(existing: TableUsageOutput | undefined, incoming: TableUsageOutput | undefined): TableUsageOutput | undefined;
66
+ /** @internal */
67
+ export declare function buildJoinsByTable(tableNames: Set<string>, joins: LiveDatabaseManifestJoinData[], preservedJoins: Map<string, LiveDatabaseManifestJoinEntry[]>, federatedSiblingTargets?: Set<string>): Map<string, LiveDatabaseManifestJoinEntry[]>;
65
68
  export declare function buildLiveDatabaseManifestShards(input: BuildLiveDatabaseManifestShardsInput): BuildLiveDatabaseManifestShardsResult;
66
69
  export {};
@@ -106,10 +106,14 @@ function joinCondition(leftTable, leftColumns, rightTable, rightColumns) {
106
106
  })
107
107
  .join(' AND ');
108
108
  }
109
- function buildJoinsByTable(tableNames, joins, preservedJoins) {
109
+ /** @internal */
110
+ export function buildJoinsByTable(tableNames, joins, preservedJoins, federatedSiblingTargets = new Set()) {
110
111
  const joinsByTable = new Map();
111
112
  for (const join of joins) {
112
- if (!tableNames.has(join.fromTable) || !tableNames.has(join.toTable)) {
113
+ const fromLocal = tableNames.has(join.fromTable);
114
+ const toLocal = tableNames.has(join.toTable);
115
+ const toSibling = federatedSiblingTargets.has(join.toTable);
116
+ if (!fromLocal || (!toLocal && !toSibling)) {
113
117
  continue;
114
118
  }
115
119
  const relationship = RELATIONSHIP_MAP[join.relationship] ?? join.relationship;
@@ -119,20 +123,24 @@ function buildJoinsByTable(tableNames, joins, preservedJoins) {
119
123
  relationship,
120
124
  source: join.source,
121
125
  });
122
- const reverseRelationship = RELATIONSHIP_INVERSE[relationship] ?? 'one_to_many';
123
- addJoinOnce(joinsByTable, join.toTable, {
124
- to: join.fromTable,
125
- on: joinCondition(join.toTable, join.toColumns, join.fromTable, join.fromColumns),
126
- relationship: reverseRelationship,
127
- source: join.source,
128
- });
126
+ // Reverse direction only when the target is a local table in THIS snapshot;
127
+ // a federated sibling has no shard here, so it gets no reverse entry.
128
+ if (toLocal) {
129
+ const reverseRelationship = RELATIONSHIP_INVERSE[relationship] ?? 'one_to_many';
130
+ addJoinOnce(joinsByTable, join.toTable, {
131
+ to: join.fromTable,
132
+ on: joinCondition(join.toTable, join.toColumns, join.fromTable, join.fromColumns),
133
+ relationship: reverseRelationship,
134
+ source: join.source,
135
+ });
136
+ }
129
137
  }
130
138
  for (const [tableName, tableJoins] of preservedJoins) {
131
139
  if (!tableNames.has(tableName)) {
132
140
  continue;
133
141
  }
134
142
  for (const join of tableJoins) {
135
- if (tableNames.has(join.to)) {
143
+ if (tableNames.has(join.to) || federatedSiblingTargets.has(join.to)) {
136
144
  addJoinOnce(joinsByTable, tableName, join);
137
145
  }
138
146
  }
@@ -141,7 +149,7 @@ function buildJoinsByTable(tableNames, joins, preservedJoins) {
141
149
  }
142
150
  export function buildLiveDatabaseManifestShards(input) {
143
151
  const tableNames = new Set(input.tables.map((table) => table.name));
144
- const joinsByTable = buildJoinsByTable(tableNames, input.joins, input.existingPreservedJoins ?? new Map());
152
+ const joinsByTable = buildJoinsByTable(tableNames, input.joins, input.existingPreservedJoins ?? new Map(), input.federatedSiblingTargets ?? new Set());
145
153
  const shards = new Map();
146
154
  for (const table of input.tables) {
147
155
  const shardKey = getShardKey(input.connectionType, table.catalog, table.db);
@@ -89,7 +89,23 @@ function assertInitIsolation(message, allowedToolIds, expectedMcpServerNames) {
89
89
  function expectedMcpServerNames(tools) {
90
90
  return tools && Object.keys(tools).length > 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set();
91
91
  }
92
- const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i;
92
+ // "session limit" is the Claude Code subscription cap ("You've hit your session
93
+ // limit · resets …"); the rest are transient 429-style throttling. All mean
94
+ // Claude Code authenticated successfully, so they must not be read as auth
95
+ // failures by the governor classifier or the auth probe.
96
+ const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|session limit|usage limit|too many requests|quota exceeded|overloaded|max_retries/i;
97
+ // The subscription cap is its own case: re-authenticating and retrying both fail
98
+ // until reset, so it gets a distinct message from transient rate limiting.
99
+ const CLAUDE_SESSION_LIMIT_MARKERS = /session limit|usage limit/i;
100
+ function describeClaudeProbeFailure(message) {
101
+ if (CLAUDE_SESSION_LIMIT_MARKERS.test(message)) {
102
+ return `Claude Code session limit reached. Wait for the reset shown, then rerun setup or the command. Details: ${message}`;
103
+ }
104
+ if (CLAUDE_RATE_LIMIT_ERROR_MARKERS.test(message)) {
105
+ return `Claude Code is rate limited. Retry shortly, then rerun setup or the command. Details: ${message}`;
106
+ }
107
+ return `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`;
108
+ }
93
109
  function normalizeClaudeResetAtMs(value) {
94
110
  if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
95
111
  return Math.round(value < 10_000_000_000 ? value * 1_000 : value);
@@ -402,7 +418,7 @@ export async function runClaudeCodeAuthProbe(input) {
402
418
  const message = error instanceof Error ? error.message : String(error);
403
419
  return {
404
420
  ok: false,
405
- message: `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`,
421
+ message: describeClaudeProbeFailure(message),
406
422
  };
407
423
  }
408
424
  }
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { emitTelemetryEvent, mcpTelemetrySampleRate, reportException, shouldEmitMcpTelemetry, } from '../../telemetry/index.js';
4
4
  import { collectTelemetryRedactionSecrets } from '../../telemetry/redaction-secrets.js';
5
- import { scrubErrorClass } from '../../telemetry/scrubber.js';
5
+ import { formatErrorDetail, scrubErrorClass } from '../../telemetry/scrubber.js';
6
6
  const connectionIdSchema = z.string().min(1);
7
7
  const unknownRecordSchema = z.record(z.string(), z.unknown());
8
8
  const tableRefSchema = z.object({
@@ -24,7 +24,7 @@ const toolAnnotations = {
24
24
  memory_ingest_status: { title: 'Memory Ingest Status', readOnlyHint: true, openWorldHint: false },
25
25
  };
26
26
  const toolDescriptions = {
27
- connection_list: 'List configured read-only data connections available to this ktx project. Use this before connection-scoped tools when the project may have multiple warehouses.',
27
+ connection_list: 'List configured read-only data connections available to this ktx project. Use this before connection-scoped tools when the project may have multiple warehouses. A "_ktx_federated" entry (when present) queries all its member databases together; use its id for cross-database joins.',
28
28
  discover_data: 'Search across ktx wiki pages, semantic-layer sources, measures, dimensions, raw tables, and columns. Example: discover_data({ query: "monthly orders by customer", connectionId: "warehouse", kinds: ["sl_source", "table"] }).',
29
29
  wiki_search: 'Search ktx wiki pages for reusable business context. Example: wiki_search({ query: "revenue recognition", limit: 5 }).',
30
30
  wiki_read: 'Read a ktx wiki page by key returned from wiki_search. Example: wiki_read({ key: "global/revenue" }).',
@@ -160,6 +160,8 @@ const connectionListOutputSchema = z.object({
160
160
  id: z.string(),
161
161
  name: z.string(),
162
162
  connectionType: z.string(),
163
+ members: z.array(z.string()).optional(),
164
+ hint: z.string().optional(),
163
165
  })),
164
166
  });
165
167
  const wikiSearchOutputSchema = z.object({
@@ -442,6 +444,25 @@ function clientTelemetryFields(getClientInfo) {
442
444
  ...(client?.version ? { mcpClientVersion: client.version } : {}),
443
445
  };
444
446
  }
447
+ // Tools registered via registerParsedTool catch their own errors and return an
448
+ // isError result, so the telemetry layer never sees the thrown Error. Recover
449
+ // the failure message from the result's text content (the same string the agent
450
+ // reads) so the outcome event is self-diagnosing.
451
+ function mcpErrorResultDetail(result) {
452
+ if (typeof result !== 'object' || result === null || !('content' in result)) {
453
+ return undefined;
454
+ }
455
+ const content = result.content;
456
+ if (!Array.isArray(content)) {
457
+ return undefined;
458
+ }
459
+ const text = content
460
+ .map((block) => typeof block === 'object' && block !== null && typeof block.text === 'string'
461
+ ? block.text
462
+ : '')
463
+ .join('\n');
464
+ return formatErrorDetail(text);
465
+ }
445
466
  function instrumentMcpServer(server, telemetry) {
446
467
  return {
447
468
  registerTool(name, config, handler) {
@@ -451,6 +472,7 @@ function instrumentMcpServer(server, telemetry) {
451
472
  const result = await handler(input, context);
452
473
  if (telemetry.io && telemetry.projectDir && shouldEmitMcpTelemetry()) {
453
474
  const isError = typeof result === 'object' && result !== null && 'isError' in result && result.isError === true;
475
+ const errorDetail = isError ? mcpErrorResultDetail(result) : undefined;
454
476
  await emitTelemetryEvent({
455
477
  name: 'mcp_request_completed',
456
478
  projectDir: telemetry.projectDir,
@@ -460,6 +482,7 @@ function instrumentMcpServer(server, telemetry) {
460
482
  outcome: isError ? 'error' : 'ok',
461
483
  durationMs: Math.max(0, performance.now() - startedAt),
462
484
  sampleRate: mcpTelemetrySampleRate(),
485
+ ...(errorDetail ? { errorDetail } : {}),
463
486
  ...clientTelemetryFields(telemetry.getClientInfo),
464
487
  },
465
488
  });
@@ -483,6 +506,7 @@ function instrumentMcpServer(server, telemetry) {
483
506
  }
484
507
  if (telemetry.io && telemetry.projectDir && shouldEmitMcpTelemetry()) {
485
508
  const errorClass = scrubErrorClass(error);
509
+ const errorDetail = formatErrorDetail(error);
486
510
  await emitTelemetryEvent({
487
511
  name: 'mcp_request_completed',
488
512
  projectDir: telemetry.projectDir,
@@ -491,6 +515,7 @@ function instrumentMcpServer(server, telemetry) {
491
515
  toolName: name,
492
516
  outcome: 'error',
493
517
  ...(errorClass ? { errorClass } : {}),
518
+ ...(errorDetail ? { errorDetail } : {}),
494
519
  durationMs: Math.max(0, performance.now() - startedAt),
495
520
  sampleRate: mcpTelemetrySampleRate(),
496
521
  ...clientTelemetryFields(telemetry.getClientInfo),
@@ -1,5 +1,8 @@
1
- import { KtxQueryError, isNativeProgrammingFault } from '../../errors.js';
2
- import { localConnectionInfoFromConfig } from '../../context/connections/local-warehouse-descriptor.js';
1
+ import { KtxExpectedError, KtxQueryError, isNativeProgrammingFault } from '../../errors.js';
2
+ import { executeProjectReadOnlySql } from '../../context/connections/project-sql-executor.js';
3
+ import { FEDERATED_CONNECTION_ID, federatedConnectionListing } from '../../context/connections/federation.js';
4
+ import { resolveConfiguredConnection } from '../../context/connections/resolve-connection.js';
5
+ import { localConnectionInfoFromConfig, } from '../../context/connections/local-warehouse-descriptor.js';
3
6
  import { createKtxEntityDetailsService } from '../../context/scan/entity-details.js';
4
7
  import { createKtxDiscoverDataService } from '../../context/search/discover.js';
5
8
  import { sqlAnalysisDialectForDriver } from '../../context/sql-analysis/dialect.js';
@@ -8,75 +11,77 @@ import { createKtxDictionarySearchService } from '../../context/sl/dictionary-se
8
11
  import { readLocalSlSource } from '../../context/sl/local-sl.js';
9
12
  import { assertSafeConnectionId } from '../../context/sl/source-files.js';
10
13
  import { readLocalKnowledgePage, searchLocalKnowledgePages } from '../wiki/local-knowledge.js';
11
- async function cleanupConnector(connector) {
12
- if (connector?.cleanup) {
13
- await connector.cleanup();
14
- }
15
- }
16
14
  async function executeValidatedReadOnlySql(project, options, input, onProgress) {
17
15
  await onProgress?.({ progress: 0, message: 'Validating SQL' });
18
- const connectionId = assertSafeConnectionId(input.connectionId);
19
- const connection = project.config.connections[connectionId];
20
- if (!connection) {
21
- throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
22
- }
23
16
  if (!options.sqlAnalysis) {
24
17
  throw new Error('sql_execution requires parser-backed SQL validation.');
25
18
  }
26
- const validation = await options.sqlAnalysis.validateReadOnly(input.sql, sqlAnalysisDialectForDriver(connection.driver));
27
- if (!validation.ok) {
28
- throw new Error(validation.error ?? 'SQL is not read-only.');
29
- }
30
19
  const createConnector = options.localScan?.createConnector;
31
20
  if (!createConnector) {
32
21
  throw new Error('sql_execution requires a local scan connector factory.');
33
22
  }
34
- let connector = null;
35
- try {
36
- connector = await createConnector(connectionId);
37
- if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
38
- throw new Error(`Connection "${connectionId}" does not support read-only SQL execution.`);
39
- }
40
- await onProgress?.({ progress: 0.3, message: 'Executing' });
41
- const result = await connector
42
- .executeReadOnly({
23
+ const isFederated = input.connectionId === FEDERATED_CONNECTION_ID;
24
+ const connectionId = isFederated ? input.connectionId : assertSafeConnectionId(input.connectionId);
25
+ const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, connectionId);
26
+ const dialect = sqlAnalysisDialectForDriver(isFederated ? 'duckdb' : connection.driver);
27
+ const validation = await options.sqlAnalysis.validateReadOnly(input.sql, dialect);
28
+ if (!validation.ok) {
29
+ // A read-only guard rejecting the agent's SQL is an expected outcome, not a
30
+ // ktx fault: classify it so reportException keeps it out of Error Tracking.
31
+ throw new KtxQueryError(validation.error ?? 'SQL is not read-only.');
32
+ }
33
+ await onProgress?.({ progress: 0.3, message: 'Executing' });
34
+ const result = await executeProjectReadOnlySql({
35
+ project,
36
+ input: {
43
37
  connectionId,
38
+ projectDir: project.projectDir,
39
+ connection,
44
40
  sql: input.sql,
45
41
  maxRows: input.maxRows,
46
- }, { runId: 'mcp-sql-execution' })
47
- .catch((error) => {
48
- // A warehouse/driver rejection (e.g. the agent's SQL failed to compile)
49
- // is a surfaced operational outcome, not a ktx fault: mark it expected
50
- // while preserving the warehouse's own diagnostics. A native JS error
51
- // (TypeError, etc.) signals a bug in connector code let it propagate
52
- // unchanged so Error Tracking still sees it.
53
- if (isNativeProgrammingFault(error)) {
54
- throw error;
55
- }
56
- throw new KtxQueryError(error instanceof Error ? error.message : String(error), { cause: error });
57
- });
58
- const response = {
59
- headers: result.headers,
60
- ...(result.headerTypes ? { headerTypes: result.headerTypes } : {}),
61
- rows: result.rows,
62
- rowCount: result.rowCount ?? result.rows.length,
63
- };
64
- await onProgress?.({ progress: 1, message: `Fetched ${response.rowCount} rows` });
65
- return response;
66
- }
67
- finally {
68
- await cleanupConnector(connector);
69
- }
42
+ },
43
+ createConnector,
44
+ runId: 'mcp-sql-execution',
45
+ }).catch((error) => {
46
+ // A warehouse/driver rejection (e.g. the agent's SQL failed to compile) is a
47
+ // surfaced operational outcome, not a ktx fault: mark it expected while
48
+ // preserving the warehouse's own diagnostics. A native JS error (TypeError,
49
+ // etc.) signals a bug in connector code — let it propagate unchanged so Error
50
+ // Tracking still sees it.
51
+ if (isNativeProgrammingFault(error) || error instanceof KtxExpectedError) {
52
+ throw error;
53
+ }
54
+ throw new KtxQueryError(error instanceof Error ? error.message : String(error), { cause: error });
55
+ });
56
+ const response = {
57
+ headers: result.headers,
58
+ ...(result.headerTypes ? { headerTypes: result.headerTypes } : {}),
59
+ rows: result.rows,
60
+ rowCount: result.rowCount ?? result.rows.length,
61
+ };
62
+ await onProgress?.({ progress: 1, message: `Fetched ${response.rowCount} rows` });
63
+ return response;
70
64
  }
71
65
  export function createLocalProjectMcpContextPorts(project, options) {
72
66
  const embeddingService = options.embeddingService;
73
67
  const ports = {
74
68
  connections: {
75
69
  async list() {
76
- return Object.entries(project.config.connections)
70
+ const configured = Object.entries(project.config.connections)
77
71
  .map(([id, config]) => localConnectionInfoFromConfig(id, config))
78
72
  .filter((connection) => connection !== null)
79
73
  .sort((a, b) => a.id.localeCompare(b.id));
74
+ const federated = federatedConnectionListing(project.config.connections, project.projectDir);
75
+ if (federated) {
76
+ configured.push({
77
+ id: federated.id,
78
+ name: federated.id,
79
+ connectionType: 'DUCKDB',
80
+ members: federated.members,
81
+ hint: federated.hint,
82
+ });
83
+ }
84
+ return configured;
80
85
  },
81
86
  },
82
87
  knowledge: {
@@ -68,6 +68,8 @@ interface KtxConnectionSummary {
68
68
  id: string;
69
69
  name: string;
70
70
  connectionType: string;
71
+ members?: string[];
72
+ hint?: string;
71
73
  }
72
74
  interface KtxConnectionsMcpPort {
73
75
  list(): Promise<KtxConnectionSummary[]>;