redash-mcp 3.0.4 → 3.1.0

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.
package/README.ko.md CHANGED
@@ -136,6 +136,10 @@ Redash → 우측 상단 프로필 → **Edit Profile** → **API Key** 복사
136
136
  | `REDASH_DEFAULT_MAX_AGE` | `0` | Redash 캐시 TTL (초) |
137
137
  | `REDASH_MCP_CACHE_TTL` | `300` | MCP 쿼리 캐시 TTL (초, 0 = 비활성화) |
138
138
  | `REDASH_MCP_CACHE_MAX_MB` | `50` | MCP 쿼리 캐시 최대 메모리 (MB) |
139
+ | `REDASH_MCP_CONFIG_DIR` | `~/.redash-mcp` | BIRD few-shot/피드백/평가/키워드맵 저장 디렉터리 |
140
+ | `REDASH_BIRD_ENABLED` | `true` | `false`로 설정하면 BIRD smart query 도구 비활성화 |
141
+ | `REDASH_HTTP_TIMEOUT_SECS` | `30` | Redash API 요청 단위 HTTP 타임아웃 |
142
+ | `ANTHROPIC_API_KEY` | — | 설정 시 BIRD smart_query가 키워드 매칭 실패 시 Claude Haiku로 테이블 선택 폴백 |
139
143
 
140
144
  ---
141
145
 
package/README.md CHANGED
@@ -136,6 +136,10 @@ Open `~/.claude/settings.json` and add:
136
136
  | `REDASH_DEFAULT_MAX_AGE` | `0` | Redash cache TTL in seconds |
137
137
  | `REDASH_MCP_CACHE_TTL` | `300` | MCP query cache TTL in seconds (0 = disabled) |
138
138
  | `REDASH_MCP_CACHE_MAX_MB` | `50` | Max memory for MCP query cache in MB |
139
+ | `REDASH_MCP_CONFIG_DIR` | `~/.redash-mcp` | Directory for BIRD few-shot, feedback, eval, keyword-map data |
140
+ | `REDASH_BIRD_ENABLED` | `true` | Set to `false` to disable BIRD smart query tools |
141
+ | `REDASH_HTTP_TIMEOUT_SECS` | `30` | Per-request HTTP timeout against the Redash API |
142
+ | `ANTHROPIC_API_KEY` | — | If set, BIRD smart_query falls back to Claude Haiku for table selection when keyword scoring fails |
139
143
 
140
144
  ---
141
145
 
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { ensureConfigDir, getDataSourcePath } from "./config.js";
3
- import { redashFetch } from "../redash-client.js";
3
+ import { redashFetch, pollQueryResult } from "../redash-client.js";
4
4
  export async function loadTestSuite(dataSourceId) {
5
5
  try {
6
6
  const raw = await readFile(getDataSourcePath("eval", dataSourceId), "utf-8");
@@ -135,19 +135,7 @@ async function executeQuery(dataSourceId, sql) {
135
135
  });
136
136
  let result;
137
137
  if (res.job) {
138
- for (let i = 0; i < 30; i++) {
139
- const job = await redashFetch(`/jobs/${res.job.id}`);
140
- if (job.job.status === 3) {
141
- result = await redashFetch(`/query_results/${job.job.query_result_id}`);
142
- break;
143
- }
144
- if (job.job.status === 4) {
145
- throw new Error(`Query failed: ${job.job.error}`);
146
- }
147
- await new Promise((r) => setTimeout(r, 1000));
148
- }
149
- if (!result)
150
- throw new Error("Query timed out");
138
+ result = await pollQueryResult(res.job.id, 30);
151
139
  }
152
140
  else {
153
141
  result = res;
@@ -29,7 +29,6 @@ export async function recordFeedback(dataSourceId, entry) {
29
29
  createdAt: new Date().toISOString(),
30
30
  };
31
31
  entries.push(newEntry);
32
- await saveFeedback(dataSourceId, entries);
33
32
  if (newEntry.rating === "down" && newEntry.correctSql && newEntry.errorType) {
34
33
  const config = await loadConfig();
35
34
  if (config.bird.feedback.enabled) {
@@ -37,10 +36,10 @@ export async function recordFeedback(dataSourceId, entry) {
37
36
  if (sameErrorCount >= config.bird.feedback.autoPromoteThreshold) {
38
37
  await promoteToFewShot(dataSourceId, newEntry);
39
38
  newEntry.promotedToFewShot = true;
40
- await saveFeedback(dataSourceId, entries);
41
39
  }
42
40
  }
43
41
  }
42
+ await saveFeedback(dataSourceId, entries);
44
43
  return newEntry;
45
44
  }
46
45
  export function classifyError(generatedSql, correctSql) {
@@ -83,7 +83,7 @@ function tokenize(text) {
83
83
  "it", "its", "this", "that", "these", "those", "what", "which",
84
84
  "who", "whom", "how", "where", "when", "why",
85
85
  "show", "give", "tell", "get", "find", "list", "display",
86
- "의", "���", "이", "은", "는", "을", "를", "에", "에서", "와", "과",
86
+ "의", "", "이", "은", "는", "을", "를", "에", "에서", "와", "과",
87
87
  "도", "로", "으로", "만", "까지", "부터", "에게", "한테", "께",
88
88
  "좀", "해줘", "알려줘", "보여줘", "해", "하는", "된", "인",
89
89
  ]);
@@ -99,8 +99,13 @@ function detectVagueness(question, prunedTables) {
99
99
  if (hasTimeKeyword && !hasTimeSpecifier) {
100
100
  clarifications.push("Which specific time period? (e.g., last 7 days, last month, 2025-01-01 ~ 2025-03-31)");
101
101
  }
102
- const wordCount = question.trim().split(/\s+/).length;
103
- if (wordCount < 4) {
102
+ const trimmed = question.trim();
103
+ const wordCount = trimmed.split(/\s+/).length;
104
+ const hasCJK = /[ㄱ-힝]/.test(trimmed);
105
+ const tooShort = hasCJK
106
+ ? trimmed.replace(/\s+/g, "").length < 6
107
+ : wordCount < 4;
108
+ if (tooShort) {
104
109
  clarifications.push("Could you provide more details about what data you need?");
105
110
  }
106
111
  const maxScore = Math.max(...prunedTables.map((t) => t.score), 0);
@@ -64,7 +64,7 @@ export function registerBirdTools(server) {
64
64
  .optional()
65
65
  .describe("Example to add (required when action=add)"),
66
66
  example_id: z.string().optional().describe("Example ID to remove (required when action=remove)"),
67
- }, { destructiveHint: true }, async ({ data_source_id, action, example, example_id }) => {
67
+ }, {}, async ({ data_source_id, action, example, example_id }) => {
68
68
  try {
69
69
  if (action === "list") {
70
70
  const examples = await loadExamples(data_source_id);
@@ -118,7 +118,7 @@ export function registerBirdTools(server) {
118
118
  generated_sql: z.string().describe("Generated SQL"),
119
119
  correct_sql: z.string().optional().describe("Correct SQL (provide when rating=down for automatic learning)"),
120
120
  rating: z.enum(["up", "down"]).describe("Rating: up (correct) or down (incorrect)"),
121
- }, { destructiveHint: true }, async ({ data_source_id, question, generated_sql, correct_sql, rating }) => {
121
+ }, {}, async ({ data_source_id, question, generated_sql, correct_sql, rating }) => {
122
122
  try {
123
123
  const entry = await recordFeedback(data_source_id, {
124
124
  question,
@@ -159,7 +159,7 @@ export function registerBirdTools(server) {
159
159
  }))
160
160
  .optional()
161
161
  .describe("List of SQL to evaluate (required when action=run)"),
162
- }, { destructiveHint: true }, async ({ data_source_id, action, test_case, test_case_id, generated_sqls }) => {
162
+ }, {}, async ({ data_source_id, action, test_case, test_case_id, generated_sqls }) => {
163
163
  try {
164
164
  if (action === "list_tests") {
165
165
  const store = await loadTestSuite(data_source_id);
@@ -227,14 +227,14 @@ export function registerBirdTools(server) {
227
227
  data_source_id: z.number().describe("Data source ID"),
228
228
  action: z.enum(["list", "add", "remove", "reset"]).describe("Action to perform"),
229
229
  mappings: z
230
- .record(z.array(z.string()))
230
+ .record(z.string(), z.array(z.string()))
231
231
  .optional()
232
232
  .describe("Mappings to add (required when action=add). e.g., {\"revenue\": [\"payment\", \"billing\"]}"),
233
233
  keywords: z
234
234
  .array(z.string())
235
235
  .optional()
236
236
  .describe("Keywords to remove (required when action=remove). e.g., [\"revenue\", \"order\"]"),
237
- }, { destructiveHint: true }, async ({ data_source_id, action, mappings, keywords }) => {
237
+ }, {}, async ({ data_source_id, action, mappings, keywords }) => {
238
238
  try {
239
239
  if (action === "list") {
240
240
  const effective = await getEffectiveMap(data_source_id);
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
2
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
7
  import { z } from "zod";
@@ -16,9 +19,20 @@ if (!REDASH_URL || !REDASH_API_KEY) {
16
19
  console.error("REDASH_URL and REDASH_API_KEY environment variables are required");
17
20
  process.exit(1);
18
21
  }
22
+ function readPackageVersion() {
23
+ try {
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ const pkgPath = path.join(here, "..", "package.json");
26
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
27
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
28
+ }
29
+ catch {
30
+ return "0.0.0";
31
+ }
32
+ }
19
33
  const server = new McpServer({
20
34
  name: "redash-mcp",
21
- version: "3.0.0",
35
+ version: readPackageVersion(),
22
36
  });
23
37
  server.tool("list_data_sources", "List connected data sources (id, name, type). Call this first to get data_source_id.", {}, { readOnlyHint: true }, async () => {
24
38
  try {
@@ -174,9 +188,10 @@ server.tool("list_queries", "List saved queries in Redash.", {
174
188
  page_size: z.number().optional().default(20).describe("Page size (max 100)"),
175
189
  }, { readOnlyHint: true }, async ({ search, page, page_size }) => {
176
190
  try {
177
- const effectivePageSize = Math.min(page_size, 100);
191
+ const effectivePageSize = Math.max(1, Math.min(page_size, 100));
192
+ const effectivePage = Math.max(1, page);
178
193
  const params = new URLSearchParams({
179
- page: String(page),
194
+ page: String(effectivePage),
180
195
  page_size: String(effectivePageSize),
181
196
  ...(search ? { q: search } : {}),
182
197
  });
@@ -200,7 +215,8 @@ server.tool("get_query_result", "Execute a saved query by ID and return results.
200
215
  query_id: z.number().describe("Saved query ID (from list_queries)"),
201
216
  max_rows: z.number().optional().default(100).describe("Max rows to return (default 100)"),
202
217
  format: z.enum(["table", "json"]).optional().default("table").describe("Output format: table (markdown) or json"),
203
- }, { readOnlyHint: true }, async ({ query_id, max_rows, format }) => {
218
+ timeout_secs: z.number().optional().default(30).describe("Query execution timeout in seconds"),
219
+ }, { readOnlyHint: true }, async ({ query_id, max_rows, format, timeout_secs }) => {
204
220
  try {
205
221
  const res = await redashFetch(`/queries/${query_id}/results`, {
206
222
  method: "POST",
@@ -208,7 +224,7 @@ server.tool("get_query_result", "Execute a saved query by ID and return results.
208
224
  });
209
225
  let result;
210
226
  if (res.job) {
211
- result = await pollQueryResult(res.job.id);
227
+ result = await pollQueryResult(res.job.id, timeout_secs);
212
228
  }
213
229
  else {
214
230
  result = res;
@@ -371,9 +387,10 @@ server.tool("list_dashboards", "List dashboards in Redash.", {
371
387
  page_size: z.number().optional().default(20).describe("Page size (max 100)"),
372
388
  }, { readOnlyHint: true }, async ({ search, page, page_size }) => {
373
389
  try {
374
- const effectivePageSize = Math.min(page_size, 100);
390
+ const effectivePageSize = Math.max(1, Math.min(page_size, 100));
391
+ const effectivePage = Math.max(1, page);
375
392
  const params = new URLSearchParams({
376
- page: String(page),
393
+ page: String(effectivePage),
377
394
  page_size: String(effectivePageSize),
378
395
  ...(search ? { q: search } : {}),
379
396
  });
@@ -447,7 +464,7 @@ server.tool("add_widget", "Add a visualization widget to a dashboard. Get visual
447
464
  dashboard_id: z.number().describe("Dashboard ID"),
448
465
  visualization_id: z.number().describe("Visualization ID (from get_query's visualizations)"),
449
466
  text: z.string().optional().default("").describe("Widget text"),
450
- width: z.number().optional().default(1).describe("Widget width (1 or 2)"),
467
+ width: z.union([z.literal(1), z.literal(2)]).optional().default(1).describe("Widget width (1 = half, 2 = full)"),
451
468
  }, { destructiveHint: true }, async ({ dashboard_id, visualization_id, text, width }) => {
452
469
  try {
453
470
  const data = await redashFetch("/widgets", {
@@ -25,6 +25,17 @@ function makeCacheKey(dataSourceId, sql) {
25
25
  function roughSize(obj) {
26
26
  return JSON.stringify(obj).length * 2;
27
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
+ }
28
39
  export function getCached(dataSourceId, sql) {
29
40
  const ttl = getCacheTtlMs();
30
41
  if (ttl === 0)
@@ -38,6 +49,9 @@ export function getCached(dataSourceId, sql) {
38
49
  cache.delete(key);
39
50
  return null;
40
51
  }
52
+ // Touch entry to maintain LRU order via insertion-order Map semantics.
53
+ cache.delete(key);
54
+ cache.set(key, entry);
41
55
  return entry.result;
42
56
  }
43
57
  export function setCached(dataSourceId, sql, result) {
@@ -48,6 +62,7 @@ export function setCached(dataSourceId, sql, result) {
48
62
  const size = roughSize(result);
49
63
  if (size > maxSize * 0.2)
50
64
  return;
65
+ purgeExpired(ttl);
51
66
  while (totalSizeBytes + size > maxSize && cache.size > 0) {
52
67
  const oldestKey = cache.keys().next().value;
53
68
  if (!oldestKey)
@@ -24,16 +24,35 @@ function validateRedashUrl(raw) {
24
24
  }
25
25
  const REDASH_URL = validateRedashUrl(process.env.REDASH_URL);
26
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
+ })();
27
31
  export { REDASH_URL, REDASH_API_KEY };
28
32
  export async function redashFetch(path, options) {
29
- const res = await fetch(`${REDASH_URL}/api${path}`, {
30
- ...options,
31
- headers: {
32
- "Authorization": `Key ${REDASH_API_KEY}`,
33
- "Content-Type": "application/json",
34
- ...options?.headers,
35
- },
36
- });
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
+ }
37
56
  if (!res.ok) {
38
57
  let hint = "";
39
58
  if (res.status === 401)
@@ -50,7 +69,9 @@ export async function redashFetch(path, options) {
50
69
  return res.json();
51
70
  }
52
71
  export async function pollQueryResult(jobId, timeoutSecs = 30) {
53
- for (let i = 0; i < timeoutSecs; i++) {
72
+ const deadline = Date.now() + timeoutSecs * 1000;
73
+ let delayMs = 250;
74
+ while (Date.now() < deadline) {
54
75
  const job = await redashFetch(`/jobs/${jobId}`);
55
76
  if (job.job.status === 3) {
56
77
  return await redashFetch(`/query_results/${job.job.query_result_id}`);
@@ -58,7 +79,11 @@ export async function pollQueryResult(jobId, timeoutSecs = 30) {
58
79
  if (job.job.status === 4) {
59
80
  throw new Error(`Query failed: ${job.job.error}`);
60
81
  }
61
- await new Promise((r) => setTimeout(r, 1000));
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);
62
87
  }
63
88
  throw new Error(`Query timed out after ${timeoutSecs}s`);
64
89
  }
@@ -73,16 +98,27 @@ export function formatAsMarkdownTable(columns, rows) {
73
98
  }
74
99
  const schemaCache = new Map();
75
100
  const CACHE_TTL_MS = 10 * 60 * 1000;
101
+ const SCHEMA_CACHE_MAX_ENTRIES = 32;
76
102
  export async function fetchSchema(dataSourceId) {
77
103
  const cached = schemaCache.get(dataSourceId);
78
104
  if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
105
+ schemaCache.delete(dataSourceId);
106
+ schemaCache.set(dataSourceId, cached);
79
107
  return cached.schema;
80
108
  }
109
+ if (cached)
110
+ schemaCache.delete(dataSourceId);
81
111
  const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
82
112
  const schema = (result.schema ?? []).map((table) => ({
83
113
  ...table,
84
114
  columns: (table.columns ?? []).map((c) => typeof c === "string" ? { name: c, type: "unknown" } : c),
85
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
+ }
86
122
  schemaCache.set(dataSourceId, { schema, ts: Date.now() });
87
123
  return schema;
88
124
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "redash-mcp",
3
3
  "mcpName": "io.github.seob717/redash-mcp",
4
- "version": "3.0.4",
4
+ "version": "3.1.0",
5
5
  "description": "MCP server for Redash",
6
6
  "license": "MIT",
7
7
  "author": "seob717",
@@ -32,17 +32,20 @@
32
32
  "scripts": {
33
33
  "build": "tsc",
34
34
  "dev": "tsx src/index.ts",
35
- "start": "node dist/index.js"
35
+ "start": "node dist/index.js",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
36
38
  },
37
39
  "dependencies": {
38
- "@anthropic-ai/sdk": "^0.80.0",
39
- "@clack/prompts": "^1.1.0",
40
- "@modelcontextprotocol/sdk": "^1.0.0",
41
- "zod": "^3.0.0"
40
+ "@anthropic-ai/sdk": "^0.95.2",
41
+ "@clack/prompts": "^1.4.0",
42
+ "@modelcontextprotocol/sdk": "^1.29.0",
43
+ "zod": "^4.4.3"
42
44
  },
43
45
  "devDependencies": {
44
46
  "@types/node": "^22.0.0",
45
47
  "tsx": "^4.0.0",
46
- "typescript": "^5.0.0"
48
+ "typescript": "^5.0.0",
49
+ "vitest": "^4.1.6"
47
50
  }
48
51
  }