redash-mcp 3.0.3 → 3.0.4

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.
@@ -5,6 +5,7 @@ import { recordFeedback } from "./feedback.js";
5
5
  import { loadTestSuite, addTestCase, removeTestCase, runEvaluation, formatEvalResults } from "./evaluation.js";
6
6
  import { loadConfig, getConfigDir } from "./config.js";
7
7
  import { getEffectiveMap, addMappings, removeMappings, resetMappings, loadKeywordMap, DEFAULT_KEYWORD_MAP } from "./keyword-map.js";
8
+ import { handleToolError } from "../tool-error.js";
8
9
  export function registerBirdTools(server) {
9
10
  if (process.env.REDASH_BIRD_ENABLED === "false")
10
11
  return;
@@ -13,36 +14,41 @@ export function registerBirdTools(server) {
13
14
  question: z.string().describe("Natural-language question (e.g., 'How many payments were completed last month?')"),
14
15
  context: z.string().optional().describe("User's answer to a previous clarification question (for multi-turn)"),
15
16
  }, { readOnlyHint: true }, async ({ data_source_id, question, context }) => {
16
- const result = await handleSmartQuery({ question, data_source_id, context });
17
- if (result.action === "clarify") {
18
- const text = [
19
- "## Clarification needed\n",
20
- "Before generating SQL, I need more information:\n",
21
- ...(result.clarificationQuestions?.map((q, i) => `${i + 1}. ${q}`) ?? []),
22
- "\nPlease provide answers, then call smart_query again with the `context` parameter containing your answers.",
23
- ].join("\n");
24
- return { content: [{ type: "text", text }] };
25
- }
26
- if (result.action === "explain") {
27
- return {
28
- content: [{ type: "text", text: result.explanation ?? "Cannot generate SQL for this question." }],
29
- };
30
- }
31
- const parts = [];
32
- if (result.schema) {
33
- parts.push(result.schema);
34
- }
35
- if (result.fewShotExamples) {
36
- parts.push(result.fewShotExamples);
37
- }
38
- if (result.complexity) {
39
- parts.push(`## Complexity: ${result.complexity.level}\n`);
17
+ try {
18
+ const result = await handleSmartQuery({ question, data_source_id, context });
19
+ if (result.action === "clarify") {
20
+ const text = [
21
+ "## Clarification needed\n",
22
+ "Before generating SQL, I need more information:\n",
23
+ ...(result.clarificationQuestions?.map((q, i) => `${i + 1}. ${q}`) ?? []),
24
+ "\nPlease provide answers, then call smart_query again with the `context` parameter containing your answers.",
25
+ ].join("\n");
26
+ return { content: [{ type: "text", text }] };
27
+ }
28
+ if (result.action === "explain") {
29
+ return {
30
+ content: [{ type: "text", text: result.explanation ?? "Cannot generate SQL for this question." }],
31
+ };
32
+ }
33
+ const parts = [];
34
+ if (result.schema) {
35
+ parts.push(result.schema);
36
+ }
37
+ if (result.fewShotExamples) {
38
+ parts.push(result.fewShotExamples);
39
+ }
40
+ if (result.complexity) {
41
+ parts.push(`## Complexity: ${result.complexity.level}\n`);
42
+ }
43
+ if (result.guidance) {
44
+ parts.push(`## Guidance\n${result.guidance}`);
45
+ }
46
+ parts.push("\n---\nUse the schema and examples above to generate SQL, then execute with `run_query`.");
47
+ return { content: [{ type: "text", text: parts.join("\n") }] };
40
48
  }
41
- if (result.guidance) {
42
- parts.push(`## Guidance\n${result.guidance}`);
49
+ catch (error) {
50
+ return handleToolError("smart_query", error);
43
51
  }
44
- parts.push("\n---\nUse the schema and examples above to generate SQL, then execute with `run_query`.");
45
- return { content: [{ type: "text", text: parts.join("\n") }] };
46
52
  });
47
53
  server.tool("manage_few_shot_examples", "Manage few-shot examples (list/add/remove). Register domain-specific examples to improve SQL accuracy.", {
48
54
  data_source_id: z.number().describe("Data source ID"),
@@ -59,47 +65,52 @@ export function registerBirdTools(server) {
59
65
  .describe("Example to add (required when action=add)"),
60
66
  example_id: z.string().optional().describe("Example ID to remove (required when action=remove)"),
61
67
  }, { destructiveHint: true }, async ({ data_source_id, action, example, example_id }) => {
62
- if (action === "list") {
63
- const examples = await loadExamples(data_source_id);
64
- if (examples.length === 0) {
65
- return { content: [{ type: "text", text: "No few-shot examples registered." }] };
68
+ try {
69
+ if (action === "list") {
70
+ const examples = await loadExamples(data_source_id);
71
+ if (examples.length === 0) {
72
+ return { content: [{ type: "text", text: "No few-shot examples registered." }] };
73
+ }
74
+ const text = examples
75
+ .map((e) => `**[${e.id}]** ${e.question}\n\`\`\`sql\n${e.sql}\n\`\`\`\nTables: ${e.tables.join(", ")} | Tags: ${e.tags.join(", ")} | Source: ${e.source}`)
76
+ .join("\n\n---\n\n");
77
+ return { content: [{ type: "text", text: `Total ${examples.length} example(s):\n\n${text}` }] };
66
78
  }
67
- const text = examples
68
- .map((e) => `**[${e.id}]** ${e.question}\n\`\`\`sql\n${e.sql}\n\`\`\`\nTables: ${e.tables.join(", ")} | Tags: ${e.tags.join(", ")} | Source: ${e.source}`)
69
- .join("\n\n---\n\n");
70
- return { content: [{ type: "text", text: `Total ${examples.length} example(s):\n\n${text}` }] };
71
- }
72
- if (action === "add") {
73
- if (!example) {
74
- return { content: [{ type: "text", text: "The example parameter is required." }] };
79
+ if (action === "add") {
80
+ if (!example) {
81
+ return { content: [{ type: "text", text: "The example parameter is required." }] };
82
+ }
83
+ const added = await addExample(data_source_id, {
84
+ question: example.question,
85
+ sql: example.sql,
86
+ tables: example.tables,
87
+ tags: example.tags ?? [],
88
+ notes: example.notes ?? "",
89
+ source: "manual",
90
+ });
91
+ return {
92
+ content: [{ type: "text", text: `Few-shot example added. (ID: ${added.id})` }],
93
+ };
75
94
  }
76
- const added = await addExample(data_source_id, {
77
- question: example.question,
78
- sql: example.sql,
79
- tables: example.tables,
80
- tags: example.tags ?? [],
81
- notes: example.notes ?? "",
82
- source: "manual",
83
- });
84
- return {
85
- content: [{ type: "text", text: `Few-shot example added. (ID: ${added.id})` }],
86
- };
87
- }
88
- if (action === "remove") {
89
- if (!example_id) {
90
- return { content: [{ type: "text", text: "The example_id parameter is required." }] };
95
+ if (action === "remove") {
96
+ if (!example_id) {
97
+ return { content: [{ type: "text", text: "The example_id parameter is required." }] };
98
+ }
99
+ const removed = await removeExample(data_source_id, example_id);
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: removed ? `Example ${example_id} has been removed.` : `Example ${example_id} not found.`,
105
+ },
106
+ ],
107
+ };
91
108
  }
92
- const removed = await removeExample(data_source_id, example_id);
93
- return {
94
- content: [
95
- {
96
- type: "text",
97
- text: removed ? `Example ${example_id} has been removed.` : `Example ${example_id} not found.`,
98
- },
99
- ],
100
- };
109
+ return { content: [{ type: "text", text: "Invalid action" }] };
110
+ }
111
+ catch (error) {
112
+ return handleToolError("manage_few_shot_examples", error);
101
113
  }
102
- return { content: [{ type: "text", text: "Invalid action" }] };
103
114
  });
104
115
  server.tool("submit_query_feedback", "Submit feedback on generated SQL. Incorrect SQL is automatically classified and may be promoted to a few-shot example.", {
105
116
  data_source_id: z.number().describe("Data source ID"),
@@ -108,20 +119,25 @@ export function registerBirdTools(server) {
108
119
  correct_sql: z.string().optional().describe("Correct SQL (provide when rating=down for automatic learning)"),
109
120
  rating: z.enum(["up", "down"]).describe("Rating: up (correct) or down (incorrect)"),
110
121
  }, { destructiveHint: true }, async ({ data_source_id, question, generated_sql, correct_sql, rating }) => {
111
- const entry = await recordFeedback(data_source_id, {
112
- question,
113
- generatedSql: generated_sql,
114
- correctSql: correct_sql,
115
- rating,
116
- });
117
- const parts = [`Feedback recorded. (ID: ${entry.id})`];
118
- if (entry.errorType) {
119
- parts.push(`Error type: ${entry.errorType}`);
122
+ try {
123
+ const entry = await recordFeedback(data_source_id, {
124
+ question,
125
+ generatedSql: generated_sql,
126
+ correctSql: correct_sql,
127
+ rating,
128
+ });
129
+ const parts = [`Feedback recorded. (ID: ${entry.id})`];
130
+ if (entry.errorType) {
131
+ parts.push(`Error type: ${entry.errorType}`);
132
+ }
133
+ if (entry.promotedToFewShot) {
134
+ parts.push("Repeated errors of the same type — auto-promoted to few-shot example.");
135
+ }
136
+ return { content: [{ type: "text", text: parts.join("\n") }] };
120
137
  }
121
- if (entry.promotedToFewShot) {
122
- parts.push("Repeated errors of the same type — auto-promoted to few-shot example.");
138
+ catch (error) {
139
+ return handleToolError("submit_query_feedback", error);
123
140
  }
124
- return { content: [{ type: "text", text: parts.join("\n") }] };
125
141
  });
126
142
  server.tool("evaluate_queries", "Manage SQL accuracy evaluation. Add/list test cases, run evaluations, and view results.", {
127
143
  data_source_id: z.number().describe("Data source ID"),
@@ -144,63 +160,68 @@ export function registerBirdTools(server) {
144
160
  .optional()
145
161
  .describe("List of SQL to evaluate (required when action=run)"),
146
162
  }, { destructiveHint: true }, async ({ data_source_id, action, test_case, test_case_id, generated_sqls }) => {
147
- if (action === "list_tests") {
148
- const store = await loadTestSuite(data_source_id);
149
- if (store.testCases.length === 0) {
150
- return { content: [{ type: "text", text: "No test cases registered." }] };
163
+ try {
164
+ if (action === "list_tests") {
165
+ const store = await loadTestSuite(data_source_id);
166
+ if (store.testCases.length === 0) {
167
+ return { content: [{ type: "text", text: "No test cases registered." }] };
168
+ }
169
+ const text = store.testCases
170
+ .map((tc) => `**[${tc.id}]** ${tc.question}\nDifficulty: ${tc.difficulty} | Tags: ${tc.tags.join(", ")}\n\`\`\`sql\n${tc.groundTruthSql}\n\`\`\``)
171
+ .join("\n\n---\n\n");
172
+ return { content: [{ type: "text", text: `Total ${store.testCases.length} test case(s):\n\n${text}` }] };
151
173
  }
152
- const text = store.testCases
153
- .map((tc) => `**[${tc.id}]** ${tc.question}\nDifficulty: ${tc.difficulty} | Tags: ${tc.tags.join(", ")}\n\`\`\`sql\n${tc.groundTruthSql}\n\`\`\``)
154
- .join("\n\n---\n\n");
155
- return { content: [{ type: "text", text: `Total ${store.testCases.length} test case(s):\n\n${text}` }] };
156
- }
157
- if (action === "add_test") {
158
- if (!test_case) {
159
- return { content: [{ type: "text", text: "The test_case parameter is required." }] };
174
+ if (action === "add_test") {
175
+ if (!test_case) {
176
+ return { content: [{ type: "text", text: "The test_case parameter is required." }] };
177
+ }
178
+ const added = await addTestCase(data_source_id, {
179
+ question: test_case.question,
180
+ groundTruthSql: test_case.ground_truth_sql,
181
+ difficulty: test_case.difficulty,
182
+ tags: test_case.tags ?? [],
183
+ });
184
+ return {
185
+ content: [{ type: "text", text: `Test case added. (ID: ${added.id})` }],
186
+ };
160
187
  }
161
- const added = await addTestCase(data_source_id, {
162
- question: test_case.question,
163
- groundTruthSql: test_case.ground_truth_sql,
164
- difficulty: test_case.difficulty,
165
- tags: test_case.tags ?? [],
166
- });
167
- return {
168
- content: [{ type: "text", text: `Test case added. (ID: ${added.id})` }],
169
- };
170
- }
171
- if (action === "remove_test") {
172
- if (!test_case_id) {
173
- return { content: [{ type: "text", text: "The test_case_id parameter is required." }] };
188
+ if (action === "remove_test") {
189
+ if (!test_case_id) {
190
+ return { content: [{ type: "text", text: "The test_case_id parameter is required." }] };
191
+ }
192
+ const removed = await removeTestCase(data_source_id, test_case_id);
193
+ return {
194
+ content: [
195
+ {
196
+ type: "text",
197
+ text: removed ? `Test case ${test_case_id} has been removed.` : `Test case ${test_case_id} not found.`,
198
+ },
199
+ ],
200
+ };
174
201
  }
175
- const removed = await removeTestCase(data_source_id, test_case_id);
176
- return {
177
- content: [
178
- {
179
- type: "text",
180
- text: removed ? `Test case ${test_case_id} has been removed.` : `Test case ${test_case_id} not found.`,
181
- },
182
- ],
183
- };
184
- }
185
- if (action === "run") {
186
- if (!generated_sqls || generated_sqls.length === 0) {
187
- return { content: [{ type: "text", text: "The generated_sqls parameter is required." }] };
202
+ if (action === "run") {
203
+ if (!generated_sqls || generated_sqls.length === 0) {
204
+ return { content: [{ type: "text", text: "The generated_sqls parameter is required." }] };
205
+ }
206
+ const run = await runEvaluation(data_source_id, generated_sqls.map((gs) => ({
207
+ testCaseId: gs.test_case_id,
208
+ generatedSql: gs.generated_sql,
209
+ })));
210
+ return { content: [{ type: "text", text: formatEvalResults(run) }] };
188
211
  }
189
- const run = await runEvaluation(data_source_id, generated_sqls.map((gs) => ({
190
- testCaseId: gs.test_case_id,
191
- generatedSql: gs.generated_sql,
192
- })));
193
- return { content: [{ type: "text", text: formatEvalResults(run) }] };
194
- }
195
- if (action === "results") {
196
- const store = await loadTestSuite(data_source_id);
197
- if (store.runs.length === 0) {
198
- return { content: [{ type: "text", text: "No evaluation runs found." }] };
212
+ if (action === "results") {
213
+ const store = await loadTestSuite(data_source_id);
214
+ if (store.runs.length === 0) {
215
+ return { content: [{ type: "text", text: "No evaluation runs found." }] };
216
+ }
217
+ const lastRun = store.runs[store.runs.length - 1];
218
+ return { content: [{ type: "text", text: formatEvalResults(lastRun) }] };
199
219
  }
200
- const lastRun = store.runs[store.runs.length - 1];
201
- return { content: [{ type: "text", text: formatEvalResults(lastRun) }] };
220
+ return { content: [{ type: "text", text: "Invalid action" }] };
221
+ }
222
+ catch (error) {
223
+ return handleToolError("evaluate_queries", error);
202
224
  }
203
- return { content: [{ type: "text", text: "Invalid action" }] };
204
225
  });
205
226
  server.tool("manage_keyword_map", "Manage keyword-to-table-name mappings. After inspecting the schema with list_tables, register domain-specific mappings to improve smart_query table-matching accuracy. e.g., {\"revenue\": [\"payment\"], \"creator\": [\"creator\"]}", {
206
227
  data_source_id: z.number().describe("Data source ID"),
@@ -214,75 +235,85 @@ export function registerBirdTools(server) {
214
235
  .optional()
215
236
  .describe("Keywords to remove (required when action=remove). e.g., [\"revenue\", \"order\"]"),
216
237
  }, { destructiveHint: true }, async ({ data_source_id, action, mappings, keywords }) => {
217
- if (action === "list") {
218
- const effective = await getEffectiveMap(data_source_id);
219
- const custom = await loadKeywordMap(data_source_id);
220
- const defaultCount = Object.keys(DEFAULT_KEYWORD_MAP).length;
221
- const customCount = Object.keys(custom).length;
222
- const lines = [
223
- `## Keyword Map (Data Source ${data_source_id})\n`,
224
- `Default: ${defaultCount} | Custom: ${customCount} | Total: ${Object.keys(effective).length}\n`,
225
- ];
226
- if (customCount > 0) {
227
- lines.push("### Custom mappings:");
228
- for (const [ko, en] of Object.entries(custom)) {
229
- lines.push(`- **${ko}** ${en.join(", ")}`);
238
+ try {
239
+ if (action === "list") {
240
+ const effective = await getEffectiveMap(data_source_id);
241
+ const custom = await loadKeywordMap(data_source_id);
242
+ const defaultCount = Object.keys(DEFAULT_KEYWORD_MAP).length;
243
+ const customCount = Object.keys(custom).length;
244
+ const lines = [
245
+ `## Keyword Map (Data Source ${data_source_id})\n`,
246
+ `Default: ${defaultCount} | Custom: ${customCount} | Total: ${Object.keys(effective).length}\n`,
247
+ ];
248
+ if (customCount > 0) {
249
+ lines.push("### Custom mappings:");
250
+ for (const [ko, en] of Object.entries(custom)) {
251
+ lines.push(`- **${ko}** → ${en.join(", ")}`);
252
+ }
253
+ lines.push("");
230
254
  }
231
- lines.push("");
255
+ lines.push("### All effective mappings:");
256
+ for (const [ko, en] of Object.entries(effective)) {
257
+ const isCustom = ko in custom;
258
+ lines.push(`- ${isCustom ? "**" : ""}${ko}${isCustom ? "** (custom)" : ""} → ${en.join(", ")}`);
259
+ }
260
+ return { content: [{ type: "text", text: lines.join("\n") }] };
232
261
  }
233
- lines.push("### All effective mappings:");
234
- for (const [ko, en] of Object.entries(effective)) {
235
- const isCustom = ko in custom;
236
- lines.push(`- ${isCustom ? "**" : ""}${ko}${isCustom ? "** (custom)" : ""} → ${en.join(", ")}`);
262
+ if (action === "add") {
263
+ if (!mappings || Object.keys(mappings).length === 0) {
264
+ return { content: [{ type: "text", text: "The mappings parameter is required. e.g., {\"revenue\": [\"payment\"]}" }] };
265
+ }
266
+ const updated = await addMappings(data_source_id, mappings);
267
+ const added = Object.keys(mappings);
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text",
272
+ text: `${added.length} keyword mapping(s) added/updated: ${added.join(", ")}`,
273
+ },
274
+ ],
275
+ };
237
276
  }
238
- return { content: [{ type: "text", text: lines.join("\n") }] };
239
- }
240
- if (action === "add") {
241
- if (!mappings || Object.keys(mappings).length === 0) {
242
- return { content: [{ type: "text", text: "The mappings parameter is required. e.g., {\"revenue\": [\"payment\"]}" }] };
277
+ if (action === "remove") {
278
+ if (!keywords || keywords.length === 0) {
279
+ return { content: [{ type: "text", text: "The keywords parameter is required." }] };
280
+ }
281
+ await removeMappings(data_source_id, keywords);
282
+ return {
283
+ content: [{ type: "text", text: `${keywords.length} custom keyword(s) removed: ${keywords.join(", ")}` }],
284
+ };
243
285
  }
244
- const updated = await addMappings(data_source_id, mappings);
245
- const added = Object.keys(mappings);
246
- return {
247
- content: [
248
- {
249
- type: "text",
250
- text: `${added.length} keyword mapping(s) added/updated: ${added.join(", ")}`,
251
- },
252
- ],
253
- };
254
- }
255
- if (action === "remove") {
256
- if (!keywords || keywords.length === 0) {
257
- return { content: [{ type: "text", text: "The keywords parameter is required." }] };
286
+ if (action === "reset") {
287
+ await resetMappings(data_source_id);
288
+ return {
289
+ content: [{ type: "text", text: "Custom mappings have been reset. Only default mappings will be used." }],
290
+ };
258
291
  }
259
- await removeMappings(data_source_id, keywords);
260
- return {
261
- content: [{ type: "text", text: `${keywords.length} custom keyword(s) removed: ${keywords.join(", ")}` }],
262
- };
292
+ return { content: [{ type: "text", text: "Invalid action" }] };
263
293
  }
264
- if (action === "reset") {
265
- await resetMappings(data_source_id);
266
- return {
267
- content: [{ type: "text", text: "Custom mappings have been reset. Only default mappings will be used." }],
268
- };
294
+ catch (error) {
295
+ return handleToolError("manage_keyword_map", error);
269
296
  }
270
- return { content: [{ type: "text", text: "Invalid action" }] };
271
297
  });
272
298
  server.tool("get_bird_config", "View BIRD SQL configuration and status.", {}, { readOnlyHint: true }, async () => {
273
- const config = await loadConfig();
274
- const configDir = getConfigDir();
275
- const lines = [
276
- "## BIRD SQL Configuration\n",
277
- `Config directory: \`${configDir}\`\n`,
278
- "### Settings:",
279
- `- Schema Pruning: ${config.bird.schemaPruning.enabled ? "ON" : "OFF"} (Top-K: ${config.bird.schemaPruning.topK})`,
280
- `- Few-shot Examples: ${config.bird.fewShot.enabled ? "ON" : "OFF"} (Max per query: ${config.bird.fewShot.maxExamplesPerQuery})`,
281
- `- Feedback Loop: ${config.bird.feedback.enabled ? "ON" : "OFF"} (Auto-promote threshold: ${config.bird.feedback.autoPromoteThreshold})`,
282
- `- Complexity Assessment: ${config.bird.complexity.enabled ? "ON" : "OFF"}`,
283
- "",
284
- `Edit \`${configDir}/config.json\` to customize settings.`,
285
- ];
286
- return { content: [{ type: "text", text: lines.join("\n") }] };
299
+ try {
300
+ const config = await loadConfig();
301
+ const configDir = getConfigDir();
302
+ const lines = [
303
+ "## BIRD SQL Configuration\n",
304
+ `Config directory: \`${configDir}\`\n`,
305
+ "### Settings:",
306
+ `- Schema Pruning: ${config.bird.schemaPruning.enabled ? "ON" : "OFF"} (Top-K: ${config.bird.schemaPruning.topK})`,
307
+ `- Few-shot Examples: ${config.bird.fewShot.enabled ? "ON" : "OFF"} (Max per query: ${config.bird.fewShot.maxExamplesPerQuery})`,
308
+ `- Feedback Loop: ${config.bird.feedback.enabled ? "ON" : "OFF"} (Auto-promote threshold: ${config.bird.feedback.autoPromoteThreshold})`,
309
+ `- Complexity Assessment: ${config.bird.complexity.enabled ? "ON" : "OFF"}`,
310
+ "",
311
+ `Edit \`${configDir}/config.json\` to customize settings.`,
312
+ ];
313
+ return { content: [{ type: "text", text: lines.join("\n") }] };
314
+ }
315
+ catch (error) {
316
+ return handleToolError("get_bird_config", error);
317
+ }
287
318
  });
288
319
  }