redash-mcp 3.1.3 → 3.1.5

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.
@@ -1,319 +0,0 @@
1
- import { z } from "zod";
2
- import { handleSmartQuery } from "./smart-query.js";
3
- import { loadExamples, addExample, removeExample } from "./few-shot.js";
4
- import { recordFeedback } from "./feedback.js";
5
- import { loadTestSuite, addTestCase, removeTestCase, runEvaluation, formatEvalResults } from "./evaluation.js";
6
- import { loadConfig, getConfigDir } from "./config.js";
7
- import { getEffectiveMap, addMappings, removeMappings, resetMappings, loadKeywordMap, DEFAULT_KEYWORD_MAP } from "./keyword-map.js";
8
- import { handleToolError } from "../tool-error.js";
9
- export function registerBirdTools(server) {
10
- if (process.env.REDASH_BIRD_ENABLED === "false")
11
- return;
12
- server.tool("smart_query", "BIRD SQL-based intelligent query tool. Analyzes natural-language questions to (1) extract relevant schema, (2) match similar few-shot examples, (3) assess complexity, (4) request clarification for ambiguous questions. Call this tool before run_query. For a new data source, first inspect the schema with list_tables, then register keyword-to-table mappings via manage_keyword_map to significantly improve accuracy.", {
13
- data_source_id: z.number().describe("Data source ID (from list_data_sources)"),
14
- question: z.string().describe("Natural-language question (e.g., 'How many payments were completed last month?')"),
15
- context: z.string().optional().describe("User's answer to a previous clarification question (for multi-turn)"),
16
- }, { readOnlyHint: true }, async ({ data_source_id, question, context }) => {
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") }] };
48
- }
49
- catch (error) {
50
- return handleToolError("smart_query", error);
51
- }
52
- });
53
- server.tool("manage_few_shot_examples", "Manage few-shot examples (list/add/remove). Register domain-specific examples to improve SQL accuracy.", {
54
- data_source_id: z.number().describe("Data source ID"),
55
- action: z.enum(["list", "add", "remove"]).describe("Action to perform"),
56
- example: z
57
- .object({
58
- question: z.string().describe("Natural-language question"),
59
- sql: z.string().describe("Correct SQL"),
60
- tables: z.array(z.string()).describe("List of table names used"),
61
- tags: z.array(z.string()).optional().describe("Tags (e.g., payments, date-filter)"),
62
- notes: z.string().optional().describe("Domain knowledge notes (e.g., payment status values are paid, pending...)"),
63
- })
64
- .optional()
65
- .describe("Example to add (required when action=add)"),
66
- example_id: z.string().optional().describe("Example ID to remove (required when action=remove)"),
67
- }, {}, async ({ data_source_id, action, example, example_id }) => {
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}` }] };
78
- }
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
- };
94
- }
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
- };
108
- }
109
- return { content: [{ type: "text", text: "Invalid action" }] };
110
- }
111
- catch (error) {
112
- return handleToolError("manage_few_shot_examples", error);
113
- }
114
- });
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.", {
116
- data_source_id: z.number().describe("Data source ID"),
117
- question: z.string().describe("Original natural-language question"),
118
- generated_sql: z.string().describe("Generated SQL"),
119
- correct_sql: z.string().optional().describe("Correct SQL (provide when rating=down for automatic learning)"),
120
- rating: z.enum(["up", "down"]).describe("Rating: up (correct) or down (incorrect)"),
121
- }, {}, async ({ data_source_id, question, generated_sql, correct_sql, rating }) => {
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") }] };
137
- }
138
- catch (error) {
139
- return handleToolError("submit_query_feedback", error);
140
- }
141
- });
142
- server.tool("evaluate_queries", "Manage SQL accuracy evaluation. Add/list test cases, run evaluations, and view results.", {
143
- data_source_id: z.number().describe("Data source ID"),
144
- action: z.enum(["list_tests", "add_test", "remove_test", "run", "results"]).describe("Action to perform"),
145
- test_case: z
146
- .object({
147
- question: z.string().describe("Natural-language question"),
148
- ground_truth_sql: z.string().describe("Ground-truth SQL"),
149
- difficulty: z.enum(["simple", "medium", "complex"]).describe("Difficulty level"),
150
- tags: z.array(z.string()).optional().describe("Tags"),
151
- })
152
- .optional()
153
- .describe("Test case to add (required when action=add_test)"),
154
- test_case_id: z.string().optional().describe("Test case ID to remove (required when action=remove_test)"),
155
- generated_sqls: z
156
- .array(z.object({
157
- test_case_id: z.string().describe("Test case ID"),
158
- generated_sql: z.string().describe("Generated SQL"),
159
- }))
160
- .optional()
161
- .describe("List of SQL to evaluate (required when action=run)"),
162
- }, {}, async ({ data_source_id, action, test_case, test_case_id, generated_sqls }) => {
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}` }] };
173
- }
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
- };
187
- }
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
- };
201
- }
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) }] };
211
- }
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) }] };
219
- }
220
- return { content: [{ type: "text", text: "Invalid action" }] };
221
- }
222
- catch (error) {
223
- return handleToolError("evaluate_queries", error);
224
- }
225
- });
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\"]}", {
227
- data_source_id: z.number().describe("Data source ID"),
228
- action: z.enum(["list", "add", "remove", "reset"]).describe("Action to perform"),
229
- mappings: z
230
- .record(z.string(), z.array(z.string()))
231
- .optional()
232
- .describe("Mappings to add (required when action=add). e.g., {\"revenue\": [\"payment\", \"billing\"]}"),
233
- keywords: z
234
- .array(z.string())
235
- .optional()
236
- .describe("Keywords to remove (required when action=remove). e.g., [\"revenue\", \"order\"]"),
237
- }, {}, async ({ data_source_id, action, mappings, keywords }) => {
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("");
254
- }
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") }] };
261
- }
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
- };
276
- }
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
- };
285
- }
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
- };
291
- }
292
- return { content: [{ type: "text", text: "Invalid action" }] };
293
- }
294
- catch (error) {
295
- return handleToolError("manage_keyword_map", error);
296
- }
297
- });
298
- server.tool("get_bird_config", "View BIRD SQL configuration and status.", {}, { readOnlyHint: true }, async () => {
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
- }
318
- });
319
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,87 +0,0 @@
1
- import { createHash } from "crypto";
2
- const cache = new Map();
3
- let totalSizeBytes = 0;
4
- function getCacheTtlMs() {
5
- const ttl = parseInt(process.env.REDASH_MCP_CACHE_TTL ?? "300", 10);
6
- return (isNaN(ttl) ? 300 : ttl) * 1000;
7
- }
8
- function getMaxSizeBytes() {
9
- const mb = parseInt(process.env.REDASH_MCP_CACHE_MAX_MB ?? "50", 10);
10
- return (isNaN(mb) ? 50 : mb) * 1024 * 1024;
11
- }
12
- function normalizeSQL(sql) {
13
- return sql
14
- .replace(/--[^\n]*/g, " ")
15
- .replace(/\/\*[\s\S]*?\*\//g, " ")
16
- .replace(/\s+/g, " ")
17
- .trim()
18
- .toLowerCase();
19
- }
20
- function makeCacheKey(dataSourceId, sql) {
21
- return createHash("sha256")
22
- .update(`${dataSourceId}:${normalizeSQL(sql)}`)
23
- .digest("hex");
24
- }
25
- function roughSize(obj) {
26
- return JSON.stringify(obj).length * 2;
27
- }
28
- function purgeExpired(ttl) {
29
- if (ttl === 0)
30
- return;
31
- const now = Date.now();
32
- for (const [key, entry] of cache) {
33
- if (now - entry.ts > ttl) {
34
- totalSizeBytes -= entry.size;
35
- cache.delete(key);
36
- }
37
- }
38
- }
39
- export function getCached(dataSourceId, sql) {
40
- const ttl = getCacheTtlMs();
41
- if (ttl === 0)
42
- return null;
43
- const key = makeCacheKey(dataSourceId, sql);
44
- const entry = cache.get(key);
45
- if (!entry)
46
- return null;
47
- if (Date.now() - entry.ts > ttl) {
48
- totalSizeBytes -= entry.size;
49
- cache.delete(key);
50
- return null;
51
- }
52
- // Touch entry to maintain LRU order via insertion-order Map semantics.
53
- cache.delete(key);
54
- cache.set(key, entry);
55
- return entry.result;
56
- }
57
- export function setCached(dataSourceId, sql, result) {
58
- const ttl = getCacheTtlMs();
59
- if (ttl === 0)
60
- return;
61
- const maxSize = getMaxSizeBytes();
62
- const size = roughSize(result);
63
- if (size > maxSize * 0.2)
64
- return;
65
- purgeExpired(ttl);
66
- while (totalSizeBytes + size > maxSize && cache.size > 0) {
67
- const oldestKey = cache.keys().next().value;
68
- if (!oldestKey)
69
- break;
70
- const old = cache.get(oldestKey);
71
- totalSizeBytes -= old.size;
72
- cache.delete(oldestKey);
73
- }
74
- const key = makeCacheKey(dataSourceId, sql);
75
- const existing = cache.get(key);
76
- if (existing)
77
- totalSizeBytes -= existing.size;
78
- cache.set(key, { result, ts: Date.now(), size });
79
- totalSizeBytes += size;
80
- }
81
- export function getCacheStats() {
82
- return {
83
- entries: cache.size,
84
- sizeMb: (totalSizeBytes / 1024 / 1024).toFixed(2),
85
- ttlSecs: getCacheTtlMs() / 1000,
86
- };
87
- }
@@ -1,124 +0,0 @@
1
- function validateRedashUrl(raw) {
2
- if (raw === undefined)
3
- return undefined;
4
- const trimmed = raw.trim();
5
- if (trimmed.length === 0)
6
- return undefined;
7
- if (/[\n\r]/.test(raw)) {
8
- throw new Error("REDASH_URL must not contain newlines");
9
- }
10
- let parsed;
11
- try {
12
- parsed = new URL(trimmed);
13
- }
14
- catch {
15
- throw new Error(`REDASH_URL is not a valid URL: ${raw}`);
16
- }
17
- if (parsed.username || parsed.password) {
18
- throw new Error("REDASH_URL must not contain credentials");
19
- }
20
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
21
- throw new Error(`REDASH_URL must use http or https scheme, got: ${parsed.protocol}`);
22
- }
23
- return trimmed.replace(/\/$/, "");
24
- }
25
- const REDASH_URL = validateRedashUrl(process.env.REDASH_URL);
26
- const REDASH_API_KEY = process.env.REDASH_API_KEY;
27
- const HTTP_TIMEOUT_MS = (() => {
28
- const raw = parseInt(process.env.REDASH_HTTP_TIMEOUT_SECS ?? "30", 10);
29
- return (isNaN(raw) || raw <= 0 ? 30 : raw) * 1000;
30
- })();
31
- export { REDASH_URL, REDASH_API_KEY };
32
- export async function redashFetch(path, options) {
33
- const controller = new AbortController();
34
- const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
35
- let res;
36
- try {
37
- res = await fetch(`${REDASH_URL}/api${path}`, {
38
- ...options,
39
- signal: options?.signal ?? controller.signal,
40
- headers: {
41
- "Authorization": `Key ${REDASH_API_KEY}`,
42
- "Content-Type": "application/json",
43
- ...options?.headers,
44
- },
45
- });
46
- }
47
- catch (err) {
48
- if (err?.name === "AbortError") {
49
- throw new Error(`Redash request timed out after ${HTTP_TIMEOUT_MS / 1000}s`);
50
- }
51
- throw err;
52
- }
53
- finally {
54
- clearTimeout(timer);
55
- }
56
- if (!res.ok) {
57
- let hint = "";
58
- if (res.status === 401)
59
- hint = " (Check your REDASH_API_KEY)";
60
- else if (res.status === 403)
61
- hint = " (Access denied for this resource)";
62
- else if (res.status === 404)
63
- hint = " (Resource not found. Check the ID)";
64
- throw new Error(`Redash API error: ${res.status} ${res.statusText}${hint}`);
65
- }
66
- if (res.status === 204 || res.headers.get("content-length") === "0") {
67
- return null;
68
- }
69
- return res.json();
70
- }
71
- export async function pollQueryResult(jobId, timeoutSecs = 30) {
72
- const deadline = Date.now() + timeoutSecs * 1000;
73
- let delayMs = 250;
74
- while (Date.now() < deadline) {
75
- const job = await redashFetch(`/jobs/${jobId}`);
76
- if (job.job.status === 3) {
77
- return await redashFetch(`/query_results/${job.job.query_result_id}`);
78
- }
79
- if (job.job.status === 4) {
80
- throw new Error(`Query failed: ${job.job.error}`);
81
- }
82
- const remaining = deadline - Date.now();
83
- if (remaining <= 0)
84
- break;
85
- await new Promise((r) => setTimeout(r, Math.min(delayMs, remaining)));
86
- delayMs = Math.min(delayMs * 2, 2000);
87
- }
88
- throw new Error(`Query timed out after ${timeoutSecs}s`);
89
- }
90
- export function formatAsMarkdownTable(columns, rows) {
91
- const escape = (s) => s.replace(/\|/g, "\\|");
92
- const header = `| ${columns.map(escape).join(" | ")} |`;
93
- const separator = `| ${columns.map(() => "---").join(" | ")} |`;
94
- const body = rows
95
- .map((row) => `| ${columns.map((c) => escape(String(row[c] ?? ""))).join(" | ")} |`)
96
- .join("\n");
97
- return `${header}\n${separator}\n${body}`;
98
- }
99
- const schemaCache = new Map();
100
- const CACHE_TTL_MS = 10 * 60 * 1000;
101
- const SCHEMA_CACHE_MAX_ENTRIES = 32;
102
- export async function fetchSchema(dataSourceId) {
103
- const cached = schemaCache.get(dataSourceId);
104
- if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
105
- schemaCache.delete(dataSourceId);
106
- schemaCache.set(dataSourceId, cached);
107
- return cached.schema;
108
- }
109
- if (cached)
110
- schemaCache.delete(dataSourceId);
111
- const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
112
- const schema = (result.schema ?? []).map((table) => ({
113
- ...table,
114
- columns: (table.columns ?? []).map((c) => typeof c === "string" ? { name: c, type: "unknown" } : c),
115
- }));
116
- while (schemaCache.size >= SCHEMA_CACHE_MAX_ENTRIES) {
117
- const oldest = schemaCache.keys().next().value;
118
- if (oldest === undefined)
119
- break;
120
- schemaCache.delete(oldest);
121
- }
122
- schemaCache.set(dataSourceId, { schema, ts: Date.now() });
123
- return schema;
124
- }
package/dist/setup.js DELETED
@@ -1,137 +0,0 @@
1
- #!/usr/bin/env node
2
- import * as fs from "fs";
3
- import * as path from "path";
4
- import * as os from "os";
5
- import { execSync } from "child_process";
6
- import * as p from "@clack/prompts";
7
- function findNpxPath() {
8
- try {
9
- const result = execSync("which npx", { encoding: "utf8" }).trim();
10
- if (result)
11
- return result;
12
- }
13
- catch { }
14
- const candidates = [
15
- "/usr/local/bin/npx",
16
- "/opt/homebrew/bin/npx",
17
- "/usr/bin/npx",
18
- ];
19
- for (const c of candidates) {
20
- if (fs.existsSync(c))
21
- return c;
22
- }
23
- return "npx";
24
- }
25
- function getDesktopConfigPath() {
26
- const platform = os.platform();
27
- if (platform === "darwin") {
28
- return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
29
- }
30
- else if (platform === "win32") {
31
- return path.join(process.env.APPDATA ?? "", "Claude", "claude_desktop_config.json");
32
- }
33
- else {
34
- return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
35
- }
36
- }
37
- function getClaudeCodeConfigPath() {
38
- return path.join(os.homedir(), ".claude", "settings.json");
39
- }
40
- export async function main() {
41
- p.intro("redash-mcp setup wizard");
42
- const targets = await p.multiselect({
43
- message: "Select installation targets (space to select, enter to confirm)",
44
- options: [
45
- { value: "desktop", label: "Claude Desktop" },
46
- { value: "cli", label: "Claude Code (CLI)" },
47
- ],
48
- required: true,
49
- });
50
- if (p.isCancel(targets)) {
51
- p.cancel("Setup cancelled.");
52
- process.exit(0);
53
- }
54
- const redashUrl = await p.text({
55
- message: "Enter your Redash URL",
56
- placeholder: "https://redash.example.com",
57
- validate(value) {
58
- if (!value)
59
- return "URL is required.";
60
- if (!value.startsWith("http://") && !value.startsWith("https://"))
61
- return "Must start with http:// or https://";
62
- },
63
- });
64
- if (p.isCancel(redashUrl)) {
65
- p.cancel("Setup cancelled.");
66
- process.exit(0);
67
- }
68
- const apiKey = await p.text({
69
- message: "Enter your Redash API key",
70
- validate(value) {
71
- if (!value)
72
- return "API key is required.";
73
- },
74
- });
75
- if (p.isCancel(apiKey)) {
76
- p.cancel("Setup cancelled.");
77
- process.exit(0);
78
- }
79
- const url = redashUrl.replace(/\/$/, "");
80
- const npxPath = findNpxPath();
81
- const mcpEntry = {
82
- command: npxPath,
83
- args: ["-y", "redash-mcp"],
84
- env: {
85
- REDASH_URL: url,
86
- REDASH_API_KEY: apiKey,
87
- },
88
- };
89
- const s = p.spinner();
90
- if (targets.includes("desktop")) {
91
- s.start("Configuring Claude Desktop...");
92
- setupDesktop(mcpEntry);
93
- s.stop("Claude Desktop configured");
94
- }
95
- if (targets.includes("cli")) {
96
- s.start("Configuring Claude Code (CLI)...");
97
- setupClaudeCode(mcpEntry);
98
- s.stop("Claude Code (CLI) configured");
99
- }
100
- p.outro("Setup complete. Restart to start using redash-mcp.");
101
- }
102
- function setupDesktop(mcpEntry) {
103
- const configPath = getDesktopConfigPath();
104
- let config = { mcpServers: {} };
105
- if (fs.existsSync(configPath)) {
106
- try {
107
- config = JSON.parse(fs.readFileSync(configPath, "utf8"));
108
- config.mcpServers ??= {};
109
- }
110
- catch {
111
- throw new Error(`Failed to read claude_desktop_config.json: ${configPath}`);
112
- }
113
- }
114
- else {
115
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
116
- }
117
- config.mcpServers["redash-mcp"] = mcpEntry;
118
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
119
- }
120
- function setupClaudeCode(mcpEntry) {
121
- const configPath = getClaudeCodeConfigPath();
122
- let config = {};
123
- if (fs.existsSync(configPath)) {
124
- try {
125
- config = JSON.parse(fs.readFileSync(configPath, "utf8"));
126
- }
127
- catch {
128
- throw new Error(`Failed to read Claude Code settings.json: ${configPath}`);
129
- }
130
- }
131
- else {
132
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
133
- }
134
- config.mcpServers ??= {};
135
- config.mcpServers["redash-mcp"] = mcpEntry;
136
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
137
- }