redash-mcp 2.2.2 → 3.0.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.
package/dist/index.js CHANGED
@@ -4,82 +4,22 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { analyzeQuery } from "./sql-guard.js";
6
6
  import { getCached, setCached } from "./query-cache.js";
7
+ import { REDASH_URL, REDASH_API_KEY, redashFetch, pollQueryResult, formatAsMarkdownTable, fetchSchema } from "./redash-client.js";
8
+ import { registerBirdTools } from "./bird/tools.js";
7
9
  if (process.argv[2] === "setup") {
8
10
  const { main } = await import("./setup.js");
9
11
  await main();
10
12
  process.exit(0);
11
13
  }
12
- const REDASH_URL = process.env.REDASH_URL?.replace(/\/$/, "");
13
- const REDASH_API_KEY = process.env.REDASH_API_KEY;
14
14
  if (!REDASH_URL || !REDASH_API_KEY) {
15
15
  console.error("REDASH_URL and REDASH_API_KEY environment variables are required");
16
16
  process.exit(1);
17
17
  }
18
- async function redashFetch(path, options) {
19
- const res = await fetch(`${REDASH_URL}/api${path}`, {
20
- ...options,
21
- headers: {
22
- "Authorization": `Key ${REDASH_API_KEY}`,
23
- "Content-Type": "application/json",
24
- ...options?.headers,
25
- },
26
- });
27
- if (!res.ok) {
28
- let hint = "";
29
- if (res.status === 401)
30
- hint = " (REDASH_API_KEY를 확인하세요)";
31
- else if (res.status === 403)
32
- hint = " (해당 리소스에 대한 접근 권한이 없습니다)";
33
- else if (res.status === 404)
34
- hint = " (리소스를 찾을 수 없습니다. ID를 확인하세요)";
35
- throw new Error(`Redash API error: ${res.status} ${res.statusText}${hint}`);
36
- }
37
- if (res.status === 204 || res.headers.get("content-length") === "0") {
38
- return null;
39
- }
40
- return res.json();
41
- }
42
- async function pollQueryResult(jobId, timeoutSecs = 30) {
43
- for (let i = 0; i < timeoutSecs; i++) {
44
- const job = await redashFetch(`/jobs/${jobId}`);
45
- if (job.job.status === 3) { // 3 = success
46
- return await redashFetch(`/query_results/${job.job.query_result_id}`);
47
- }
48
- if (job.job.status === 4) { // 4 = failure
49
- throw new Error(`Query failed: ${job.job.error}`);
50
- }
51
- await new Promise((r) => setTimeout(r, 1000));
52
- }
53
- throw new Error(`Query timed out after ${timeoutSecs}s`);
54
- }
55
- function formatAsMarkdownTable(columns, rows) {
56
- const escape = (s) => s.replace(/\|/g, "\\|");
57
- const header = `| ${columns.map(escape).join(" | ")} |`;
58
- const separator = `| ${columns.map(() => "---").join(" | ")} |`;
59
- const body = rows
60
- .map((row) => `| ${columns.map((c) => escape(String(row[c] ?? ""))).join(" | ")} |`)
61
- .join("\n");
62
- return `${header}\n${separator}\n${body}`;
63
- }
64
- // Schema cache: data_source_id → { schema, timestamp }
65
- const schemaCache = new Map();
66
- const CACHE_TTL_MS = 10 * 60 * 1000; // 10분
67
- async function fetchSchema(dataSourceId) {
68
- const cached = schemaCache.get(dataSourceId);
69
- if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
70
- return cached.schema;
71
- }
72
- const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
73
- const schema = result.schema ?? [];
74
- schemaCache.set(dataSourceId, { schema, ts: Date.now() });
75
- return schema;
76
- }
77
18
  const server = new McpServer({
78
19
  name: "redash-mcp",
79
- version: "2.0.2",
20
+ version: "3.0.0",
80
21
  });
81
- // ─── Data Sources ────────────────────────────────────────────────────────────
82
- server.tool("list_data_sources", "Redash에 연결된 데이터소스 목록(id, name, type)을 반환합니다. 항상 이 툴을 먼저 호출해 data_source_id를 확인하세요.", {}, async () => {
22
+ server.tool("list_data_sources", "List connected data sources (id, name, type). Call this first to get data_source_id.", {}, { readOnlyHint: true }, async () => {
83
23
  const data = await redashFetch("/data_sources");
84
24
  const sources = data.map((ds) => ({
85
25
  id: ds.id,
@@ -90,25 +30,29 @@ server.tool("list_data_sources", "Redash에 연결된 데이터소스 목록(id,
90
30
  content: [{ type: "text", text: JSON.stringify(sources, null, 2) }],
91
31
  };
92
32
  });
93
- // ─── Schema ───────────────────────────────────────────────────────────────────
94
- server.tool("list_tables", "데이터소스의 테이블 목록을 반환합니다. keyword로 관련 테이블을 검색할 수 있습니다. SQL 작성 전 반드시 이 툴로 테이블명을 확인하고, get_table_columns로 컬럼을 확인하세요.", {
95
- data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
96
- keyword: z.string().optional().describe("테이블명 검색 키워드 (예: 'user', 'order')"),
97
- }, async ({ data_source_id, keyword }) => {
33
+ server.tool("list_tables", "List tables in a data source. Use keyword to filter by name. Verify table names here before writing SQL.", {
34
+ data_source_id: z.number().describe("Data source ID from list_data_sources"),
35
+ keyword: z.string().optional().describe("Filter keyword for table names (e.g., 'user', 'order')"),
36
+ }, { readOnlyHint: true }, async ({ data_source_id, keyword }) => {
98
37
  const schema = await fetchSchema(data_source_id);
99
38
  let tables = schema.map((t) => t.name);
100
39
  if (keyword) {
101
40
  tables = tables.filter((name) => name.toLowerCase().includes(keyword.toLowerCase()));
102
41
  }
103
- const summary = `총 ${tables.length}개 테이블${keyword ? ` ('${keyword}' 포함)` : ""}\n\n${tables.join("\n")}`;
42
+ const total = tables.length;
43
+ const MAX_TABLES = 200;
44
+ const truncated = tables.length > MAX_TABLES;
45
+ if (truncated)
46
+ tables = tables.slice(0, MAX_TABLES);
47
+ const summary = `${total} tables${keyword ? ` (matching '${keyword}')` : ""}${truncated ? ` (showing first ${MAX_TABLES}, use keyword to filter)` : ""}\n\n${tables.join("\n")}`;
104
48
  return {
105
49
  content: [{ type: "text", text: summary }],
106
50
  };
107
51
  });
108
- server.tool("get_table_columns", "테이블의 컬럼명과 타입을 반환합니다. 쉼표로 여러 테이블을 동시에 조회할 있습니다. SQL 작성 실제 컬럼명을 반드시 확인하세요.", {
109
- data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
110
- table_name: z.string().describe("테이블명, 쉼표로 여러 개 가능 (예: 'users' 또는 'users,orders')"),
111
- }, async ({ data_source_id, table_name }) => {
52
+ server.tool("get_table_columns", "Get column names and types for one or more tables (comma-separated). Verify columns before writing SQL.", {
53
+ data_source_id: z.number().describe("Data source ID from list_data_sources"),
54
+ table_name: z.string().describe("Table name(s), comma-separated (e.g., 'users' or 'users,orders')"),
55
+ }, { readOnlyHint: true }, async ({ data_source_id, table_name }) => {
112
56
  const schema = await fetchSchema(data_source_id);
113
57
  const tableNames = table_name.split(",").map((n) => n.trim()).filter(Boolean);
114
58
  const results = [];
@@ -118,41 +62,37 @@ server.tool("get_table_columns", "테이블의 컬럼명과 타입을 반환합
118
62
  table = schema.find((t) => t.name.toLowerCase().includes(name.toLowerCase()));
119
63
  }
120
64
  if (!table) {
121
- results.push(`테이블 '${name}' 찾을 없습니다. list_tables 정확한 테이블명을 확인하세요.`);
65
+ results.push(`Table '${name}' not found. Use list_tables to verify the table name.`);
122
66
  continue;
123
67
  }
124
- const cols = (table.columns ?? []).map((c) => `${c.name} (${c.type ?? "unknown"})`).join("\n");
68
+ const cols = (table.columns ?? []).map((c) => typeof c === "string" ? c : `${c.name} (${c.type ?? "unknown"})`).join("\n");
125
69
  results.push(`[${table.name}]\n${cols}`);
126
70
  }
127
71
  return {
128
72
  content: [{ type: "text", text: results.join("\n\n") }],
129
73
  };
130
74
  });
131
- // ─── Query Execution ──────────────────────────────────────────────────────────
132
75
  const DEFAULT_MAX_AGE = parseInt(process.env.REDASH_DEFAULT_MAX_AGE ?? "0", 10) || 0;
133
- server.tool("run_query", "SQL 데이터소스에 직접 실행하고 결과를 반환합니다. SQL 작성 list_tables get_table_columns 스키마를 먼저 확인하세요.", {
134
- data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
135
- query: z.string().describe("실행할 SQL 쿼리"),
136
- max_age: z.number().optional().describe("Redash 캐시 유지 시간(초). 미지정 REDASH_DEFAULT_MAX_AGE 환경변수 사용"),
137
- max_rows: z.number().optional().default(100).describe("반환할 최대 (기본 100)"),
138
- format: z.enum(["table", "json"]).optional().default("table").describe("결과 포맷: table(마크다운) 또는 json"),
139
- timeout_secs: z.number().optional().default(30).describe("쿼리 실행 타임아웃(초)"),
140
- }, async ({ data_source_id, query, max_age, max_rows, format, timeout_secs }) => {
141
- // 1. SQL 안전 가드
76
+ server.tool("run_query", "Execute SQL against a data source and return results. Check schema with list_tables and get_table_columns first.", {
77
+ data_source_id: z.number().describe("Data source ID from list_data_sources"),
78
+ query: z.string().describe("SQL query to execute"),
79
+ max_age: z.number().optional().describe("Redash cache TTL in seconds. Defaults to REDASH_DEFAULT_MAX_AGE env var"),
80
+ max_rows: z.number().optional().default(100).describe("Max rows to return (default 100)"),
81
+ format: z.enum(["table", "json"]).optional().default("table").describe("Output format: table (markdown) or json"),
82
+ timeout_secs: z.number().optional().default(30).describe("Query execution timeout in seconds"),
83
+ }, { readOnlyHint: true }, async ({ data_source_id, query, max_age, max_rows, format, timeout_secs }) => {
142
84
  const guard = analyzeQuery(query);
143
85
  if (guard.blocked) {
144
86
  return { content: [{ type: "text", text: guard.message }] };
145
87
  }
146
- // auto-LIMIT이 적용된 경우 변환된 쿼리 사용
147
88
  const effectiveQuery = guard.modifiedQuery ?? query;
148
89
  const effectiveMaxAge = max_age ?? DEFAULT_MAX_AGE;
149
- // 2. MCP 레이어 캐시 조회
150
90
  const cached = getCached(data_source_id, effectiveQuery);
151
91
  if (cached) {
152
92
  const { rows, columns, warningPrefix } = cached;
153
93
  const displayRows = rows.slice(0, max_rows);
154
94
  const truncated = rows.length > max_rows
155
- ? `\n⚠️ 전체 ${rows.length} ${max_rows}행만 표시합니다.`
95
+ ? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
156
96
  : "";
157
97
  let body;
158
98
  if (format === "json") {
@@ -161,17 +101,16 @@ server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과
161
101
  else {
162
102
  body = formatAsMarkdownTable(columns, displayRows);
163
103
  }
164
- const cacheNote = "📦 MCP 캐시에서 반환된 결과입니다.\n\n";
104
+ const cacheNote = "Returned from MCP cache.\n\n";
165
105
  return {
166
106
  content: [
167
107
  {
168
108
  type: "text",
169
- text: `${warningPrefix}${cacheNote}${rows.length} | 컬럼: ${columns.join(", ")}${truncated}\n\n${body}`,
109
+ text: `${warningPrefix}${cacheNote}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
170
110
  },
171
111
  ],
172
112
  };
173
113
  }
174
- // 3. Redash API 호출
175
114
  const res = await redashFetch("/query_results", {
176
115
  method: "POST",
177
116
  body: JSON.stringify({ data_source_id, query: effectiveQuery, max_age: effectiveMaxAge }),
@@ -186,12 +125,11 @@ server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과
186
125
  const qr = result.query_result;
187
126
  const rows = qr.data.rows;
188
127
  const columns = qr.data.columns.map((c) => c.name);
189
- // 4. MCP 캐시에 저장
190
128
  const warningPrefix = guard.message ? `${guard.message}\n\n` : "";
191
129
  setCached(data_source_id, effectiveQuery, { rows, columns, warningPrefix });
192
130
  const displayRows = rows.slice(0, max_rows);
193
131
  const truncated = rows.length > max_rows
194
- ? `\n⚠️ 전체 ${rows.length} ${max_rows}행만 표시합니다.`
132
+ ? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
195
133
  : "";
196
134
  let body;
197
135
  if (format === "json") {
@@ -204,20 +142,20 @@ server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과
204
142
  content: [
205
143
  {
206
144
  type: "text",
207
- text: `${warningPrefix}${rows.length} | 컬럼: ${columns.join(", ")}${truncated}\n\n${body}`,
145
+ text: `${warningPrefix}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
208
146
  },
209
147
  ],
210
148
  };
211
149
  });
212
- // ─── Saved Queries ────────────────────────────────────────────────────────────
213
- server.tool("list_queries", "Redash에 저장된 쿼리 목록을 조회합니다.", {
214
- search: z.string().optional().describe("검색어"),
150
+ server.tool("list_queries", "List saved queries in Redash.", {
151
+ search: z.string().optional().describe("Search keyword"),
215
152
  page: z.number().optional().default(1),
216
- page_size: z.number().optional().default(20),
217
- }, async ({ search, page, page_size }) => {
153
+ page_size: z.number().optional().default(20).describe("Page size (max 100)"),
154
+ }, { readOnlyHint: true }, async ({ search, page, page_size }) => {
155
+ const effectivePageSize = Math.min(page_size, 100);
218
156
  const params = new URLSearchParams({
219
157
  page: String(page),
220
- page_size: String(page_size),
158
+ page_size: String(effectivePageSize),
221
159
  ...(search ? { q: search } : {}),
222
160
  });
223
161
  const data = await redashFetch(`/queries?${params}`);
@@ -232,11 +170,11 @@ server.tool("list_queries", "Redash에 저장된 쿼리 목록을 조회합니
232
170
  content: [{ type: "text", text: JSON.stringify(queries, null, 2) }],
233
171
  };
234
172
  });
235
- server.tool("get_query_result", "저장된 Redash 쿼리를 ID 실행하고 결과를 반환합니다.", {
236
- query_id: z.number().describe("저장된 쿼리 ID (list_queries로 확인)"),
237
- max_rows: z.number().optional().default(100).describe("반환할 최대 (기본 100)"),
238
- format: z.enum(["table", "json"]).optional().default("table").describe("결과 포맷: table(마크다운) 또는 json"),
239
- }, async ({ query_id, max_rows, format }) => {
173
+ server.tool("get_query_result", "Execute a saved query by ID and return results.", {
174
+ query_id: z.number().describe("Saved query ID (from list_queries)"),
175
+ max_rows: z.number().optional().default(100).describe("Max rows to return (default 100)"),
176
+ format: z.enum(["table", "json"]).optional().default("table").describe("Output format: table (markdown) or json"),
177
+ }, { readOnlyHint: true }, async ({ query_id, max_rows, format }) => {
240
178
  const res = await redashFetch(`/queries/${query_id}/results`, {
241
179
  method: "POST",
242
180
  body: JSON.stringify({}),
@@ -253,7 +191,7 @@ server.tool("get_query_result", "저장된 Redash 쿼리를 ID로 실행하고
253
191
  const columns = qr.data.columns.map((c) => c.name);
254
192
  const displayRows = rows.slice(0, max_rows);
255
193
  const truncated = rows.length > max_rows
256
- ? `\n⚠️ 전체 ${rows.length} ${max_rows}행만 표시합니다.`
194
+ ? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
257
195
  : "";
258
196
  let body;
259
197
  if (format === "json") {
@@ -266,14 +204,14 @@ server.tool("get_query_result", "저장된 Redash 쿼리를 ID로 실행하고
266
204
  content: [
267
205
  {
268
206
  type: "text",
269
- text: `쿼리 ID: ${query_id}\n${rows.length} | 컬럼: ${columns.join(", ")}${truncated}\n\n${body}`,
207
+ text: `Query ID: ${query_id}\n${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
270
208
  },
271
209
  ],
272
210
  };
273
211
  });
274
- server.tool("get_query", "저장된 쿼리의 상세 정보(SQL, 시각화, 태그 등)를 반환합니다.", {
275
- query_id: z.number().describe("쿼리 ID (list_queries로 확인)"),
276
- }, async ({ query_id }) => {
212
+ server.tool("get_query", "Get saved query details (SQL, visualizations, tags).", {
213
+ query_id: z.number().describe("Query ID (from list_queries)"),
214
+ }, { readOnlyHint: true }, async ({ query_id }) => {
277
215
  const data = await redashFetch(`/queries/${query_id}`);
278
216
  const result = {
279
217
  id: data.id,
@@ -293,13 +231,13 @@ server.tool("get_query", "저장된 쿼리의 상세 정보(SQL, 시각화, 태
293
231
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
294
232
  };
295
233
  });
296
- server.tool("create_query", " 쿼리를 Redash에 저장합니다.", {
297
- name: z.string().describe("쿼리 이름"),
298
- query: z.string().describe("SQL 쿼리"),
299
- data_source_id: z.number().describe("데이터소스 ID (list_data_sources로 확인)"),
300
- description: z.string().optional().describe("쿼리 설명"),
301
- tags: z.array(z.string()).optional().describe("태그 목록"),
302
- }, async ({ name, query, data_source_id, description, tags }) => {
234
+ server.tool("create_query", "Save a new query to Redash.", {
235
+ name: z.string().describe("Query name"),
236
+ query: z.string().describe("SQL query"),
237
+ data_source_id: z.number().describe("Data source ID from list_data_sources"),
238
+ description: z.string().optional().describe("Query description"),
239
+ tags: z.array(z.string()).optional().describe("Tags"),
240
+ }, { destructiveHint: true }, async ({ name, query, data_source_id, description, tags }) => {
303
241
  const body = { name, query, data_source_id };
304
242
  if (description !== undefined)
305
243
  body.description = description;
@@ -318,13 +256,13 @@ server.tool("create_query", "새 쿼리를 Redash에 저장합니다.", {
318
256
  ],
319
257
  };
320
258
  });
321
- server.tool("update_query", "저장된 쿼리의 이름, SQL, 설명, 태그를 수정합니다.", {
322
- query_id: z.number().describe("수정할 쿼리 ID"),
323
- name: z.string().optional().describe(" 이름"),
324
- query: z.string().optional().describe(" SQL"),
325
- description: z.string().optional().describe(" 설명"),
326
- tags: z.array(z.string()).optional().describe(" 태그 목록"),
327
- }, async ({ query_id, name, query, description, tags }) => {
259
+ server.tool("update_query", "Update a saved query's name, SQL, description, or tags.", {
260
+ query_id: z.number().describe("Query ID to update"),
261
+ name: z.string().optional().describe("New name"),
262
+ query: z.string().optional().describe("New SQL"),
263
+ description: z.string().optional().describe("New description"),
264
+ tags: z.array(z.string()).optional().describe("New tags"),
265
+ }, { destructiveHint: true }, async ({ query_id, name, query, description, tags }) => {
328
266
  const body = {};
329
267
  if (name !== undefined)
330
268
  body.name = name;
@@ -347,9 +285,9 @@ server.tool("update_query", "저장된 쿼리의 이름, SQL, 설명, 태그를
347
285
  ],
348
286
  };
349
287
  });
350
- server.tool("fork_query", "기존 쿼리를 복제(fork)합니다.", {
351
- query_id: z.number().describe("복제할 쿼리 ID"),
352
- }, async ({ query_id }) => {
288
+ server.tool("fork_query", "Fork (duplicate) an existing query.", {
289
+ query_id: z.number().describe("Query ID to fork"),
290
+ }, { destructiveHint: true }, async ({ query_id }) => {
353
291
  const data = await redashFetch(`/queries/${query_id}/fork`, {
354
292
  method: "POST",
355
293
  body: JSON.stringify({}),
@@ -363,23 +301,23 @@ server.tool("fork_query", "기존 쿼리를 복제(fork)합니다.", {
363
301
  ],
364
302
  };
365
303
  });
366
- server.tool("archive_query", "쿼리를 아카이브(삭제)합니다. 복구 불가합니다.", {
367
- query_id: z.number().describe("아카이브할 쿼리 ID"),
368
- }, async ({ query_id }) => {
304
+ server.tool("archive_query", "Archive (delete) a query. This action is irreversible.", {
305
+ query_id: z.number().describe("Query ID to archive"),
306
+ }, { destructiveHint: true }, async ({ query_id }) => {
369
307
  await redashFetch(`/queries/${query_id}`, { method: "DELETE" });
370
308
  return {
371
- content: [{ type: "text", text: `쿼리 ${query_id} 아카이브되었습니다.` }],
309
+ content: [{ type: "text", text: `Query ${query_id} has been archived.` }],
372
310
  };
373
311
  });
374
- // ─── Dashboards ───────────────────────────────────────────────────────────────
375
- server.tool("list_dashboards", "Redash 대시보드 목록을 조회합니다.", {
376
- search: z.string().optional().describe("검색어"),
312
+ server.tool("list_dashboards", "List dashboards in Redash.", {
313
+ search: z.string().optional().describe("Search keyword"),
377
314
  page: z.number().optional().default(1),
378
- page_size: z.number().optional().default(20),
379
- }, async ({ search, page, page_size }) => {
315
+ page_size: z.number().optional().default(20).describe("Page size (max 100)"),
316
+ }, { readOnlyHint: true }, async ({ search, page, page_size }) => {
317
+ const effectivePageSize = Math.min(page_size, 100);
380
318
  const params = new URLSearchParams({
381
319
  page: String(page),
382
- page_size: String(page_size),
320
+ page_size: String(effectivePageSize),
383
321
  ...(search ? { q: search } : {}),
384
322
  });
385
323
  const data = await redashFetch(`/dashboards?${params}`);
@@ -394,9 +332,9 @@ server.tool("list_dashboards", "Redash 대시보드 목록을 조회합니다.",
394
332
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
395
333
  };
396
334
  });
397
- server.tool("get_dashboard", "대시보드 상세 정보와 위젯(시각화) 목록을 반환합니다.", {
398
- dashboard_id_or_slug: z.string().describe("대시보드 ID 또는 slug (list_dashboards로 확인)"),
399
- }, async ({ dashboard_id_or_slug }) => {
335
+ server.tool("get_dashboard", "Get dashboard details including widgets and visualizations.", {
336
+ dashboard_id_or_slug: z.string().describe("Dashboard ID or slug (from list_dashboards)"),
337
+ }, { readOnlyHint: true }, async ({ dashboard_id_or_slug }) => {
400
338
  const data = await redashFetch(`/dashboards/${dashboard_id_or_slug}`);
401
339
  const result = {
402
340
  id: data.id,
@@ -418,9 +356,9 @@ server.tool("get_dashboard", "대시보드 상세 정보와 위젯(시각화)
418
356
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
419
357
  };
420
358
  });
421
- server.tool("create_dashboard", " 대시보드를 생성합니다.", {
422
- name: z.string().describe("대시보드 이름"),
423
- }, async ({ name }) => {
359
+ server.tool("create_dashboard", "Create a new dashboard.", {
360
+ name: z.string().describe("Dashboard name"),
361
+ }, { destructiveHint: true }, async ({ name }) => {
424
362
  const data = await redashFetch("/dashboards", {
425
363
  method: "POST",
426
364
  body: JSON.stringify({ name }),
@@ -434,12 +372,12 @@ server.tool("create_dashboard", "새 대시보드를 생성합니다.", {
434
372
  ],
435
373
  };
436
374
  });
437
- server.tool("add_widget", "대시보드에 시각화 위젯을 추가합니다. visualization_id get_query로 확인하세요.", {
438
- dashboard_id: z.number().describe("대시보드 ID"),
439
- visualization_id: z.number().describe("추가할 시각화 ID (get_query visualizations에서 확인)"),
440
- text: z.string().optional().default("").describe("위젯 텍스트"),
441
- width: z.number().optional().default(1).describe("위젯 너비 (1 또는 2)"),
442
- }, async ({ dashboard_id, visualization_id, text, width }) => {
375
+ server.tool("add_widget", "Add a visualization widget to a dashboard. Get visualization_id from get_query.", {
376
+ dashboard_id: z.number().describe("Dashboard ID"),
377
+ visualization_id: z.number().describe("Visualization ID (from get_query's visualizations)"),
378
+ text: z.string().optional().default("").describe("Widget text"),
379
+ width: z.number().optional().default(1).describe("Widget width (1 or 2)"),
380
+ }, { destructiveHint: true }, async ({ dashboard_id, visualization_id, text, width }) => {
443
381
  const data = await redashFetch("/widgets", {
444
382
  method: "POST",
445
383
  body: JSON.stringify({
@@ -459,8 +397,7 @@ server.tool("add_widget", "대시보드에 시각화 위젯을 추가합니다.
459
397
  ],
460
398
  };
461
399
  });
462
- // ─── Alerts ───────────────────────────────────────────────────────────────────
463
- server.tool("list_alerts", "Redash 알림(Alert) 목록을 조회합니다.", {}, async () => {
400
+ server.tool("list_alerts", "List alerts in Redash.", {}, { readOnlyHint: true }, async () => {
464
401
  const data = await redashFetch("/alerts");
465
402
  const alerts = Array.isArray(data) ? data : [];
466
403
  const result = alerts.map((alert) => ({
@@ -475,9 +412,9 @@ server.tool("list_alerts", "Redash 알림(Alert) 목록을 조회합니다.", {}
475
412
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
476
413
  };
477
414
  });
478
- server.tool("get_alert", "특정 알림의 상세 정보(임계값, 대상 쿼리, 상태)를 반환합니다.", {
479
- alert_id: z.number().describe("알림 ID (list_alerts로 확인)"),
480
- }, async ({ alert_id }) => {
415
+ server.tool("get_alert", "Get alert details (threshold, linked query, state).", {
416
+ alert_id: z.number().describe("Alert ID (from list_alerts)"),
417
+ }, { readOnlyHint: true }, async ({ alert_id }) => {
481
418
  const alert = await redashFetch(`/alerts/${alert_id}`);
482
419
  const result = {
483
420
  id: alert.id,
@@ -498,14 +435,14 @@ server.tool("get_alert", "특정 알림의 상세 정보(임계값, 대상 쿼
498
435
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
499
436
  };
500
437
  });
501
- server.tool("create_alert", " 알림을 생성합니다. 쿼리 결과의 특정 컬럼값이 임계값을 초과하면 알림을 발생시킵니다.", {
502
- name: z.string().describe("알림 이름"),
503
- query_id: z.number().describe("모니터링할 쿼리 ID"),
504
- column: z.string().describe("모니터링할 컬럼명"),
505
- op: z.enum(["greater than", "less than", "equals"]).describe("비교 연산자"),
506
- value: z.number().describe("임계값"),
507
- rearm: z.number().optional().default(0).describe("재알림 간격(초), 0 한번만"),
508
- }, async ({ name, query_id, column, op, value, rearm }) => {
438
+ server.tool("create_alert", "Create a new alert. Triggers when a query result column crosses a threshold.", {
439
+ name: z.string().describe("Alert name"),
440
+ query_id: z.number().describe("Query ID to monitor"),
441
+ column: z.string().describe("Column name to monitor"),
442
+ op: z.enum(["greater than", "less than", "equals"]).describe("Comparison operator"),
443
+ value: z.number().describe("Threshold value"),
444
+ rearm: z.number().optional().default(0).describe("Rearm interval in seconds (0 = fire once)"),
445
+ }, { destructiveHint: true }, async ({ name, query_id, column, op, value, rearm }) => {
509
446
  const data = await redashFetch("/alerts", {
510
447
  method: "POST",
511
448
  body: JSON.stringify({
@@ -524,5 +461,6 @@ server.tool("create_alert", "새 알림을 생성합니다. 쿼리 결과의 특
524
461
  ],
525
462
  };
526
463
  });
464
+ registerBirdTools(server);
527
465
  const transport = new StdioServerTransport();
528
466
  await server.connect(transport);
@@ -46,10 +46,8 @@ export function setCached(dataSourceId, sql, result) {
46
46
  return;
47
47
  const maxSize = getMaxSizeBytes();
48
48
  const size = roughSize(result);
49
- // 단일 결과가 전체 한도의 20% 초과 시 캐시하지 않음
50
49
  if (size > maxSize * 0.2)
51
50
  return;
52
- // 한도 초과 시 오래된 항목부터 제거
53
51
  while (totalSizeBytes + size > maxSize && cache.size > 0) {
54
52
  const oldestKey = cache.keys().next().value;
55
53
  if (!oldestKey)
@@ -0,0 +1,64 @@
1
+ const REDASH_URL = process.env.REDASH_URL?.replace(/\/$/, "");
2
+ const REDASH_API_KEY = process.env.REDASH_API_KEY;
3
+ export { REDASH_URL, REDASH_API_KEY };
4
+ export async function redashFetch(path, options) {
5
+ const res = await fetch(`${REDASH_URL}/api${path}`, {
6
+ ...options,
7
+ headers: {
8
+ "Authorization": `Key ${REDASH_API_KEY}`,
9
+ "Content-Type": "application/json",
10
+ ...options?.headers,
11
+ },
12
+ });
13
+ if (!res.ok) {
14
+ let hint = "";
15
+ if (res.status === 401)
16
+ hint = " (Check your REDASH_API_KEY)";
17
+ else if (res.status === 403)
18
+ hint = " (Access denied for this resource)";
19
+ else if (res.status === 404)
20
+ hint = " (Resource not found. Check the ID)";
21
+ throw new Error(`Redash API error: ${res.status} ${res.statusText}${hint}`);
22
+ }
23
+ if (res.status === 204 || res.headers.get("content-length") === "0") {
24
+ return null;
25
+ }
26
+ return res.json();
27
+ }
28
+ export async function pollQueryResult(jobId, timeoutSecs = 30) {
29
+ for (let i = 0; i < timeoutSecs; i++) {
30
+ const job = await redashFetch(`/jobs/${jobId}`);
31
+ if (job.job.status === 3) {
32
+ return await redashFetch(`/query_results/${job.job.query_result_id}`);
33
+ }
34
+ if (job.job.status === 4) {
35
+ throw new Error(`Query failed: ${job.job.error}`);
36
+ }
37
+ await new Promise((r) => setTimeout(r, 1000));
38
+ }
39
+ throw new Error(`Query timed out after ${timeoutSecs}s`);
40
+ }
41
+ export function formatAsMarkdownTable(columns, rows) {
42
+ const escape = (s) => s.replace(/\|/g, "\\|");
43
+ const header = `| ${columns.map(escape).join(" | ")} |`;
44
+ const separator = `| ${columns.map(() => "---").join(" | ")} |`;
45
+ const body = rows
46
+ .map((row) => `| ${columns.map((c) => escape(String(row[c] ?? ""))).join(" | ")} |`)
47
+ .join("\n");
48
+ return `${header}\n${separator}\n${body}`;
49
+ }
50
+ const schemaCache = new Map();
51
+ const CACHE_TTL_MS = 10 * 60 * 1000;
52
+ export async function fetchSchema(dataSourceId) {
53
+ const cached = schemaCache.get(dataSourceId);
54
+ if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
55
+ return cached.schema;
56
+ }
57
+ const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
58
+ const schema = (result.schema ?? []).map((table) => ({
59
+ ...table,
60
+ columns: (table.columns ?? []).map((c) => typeof c === "string" ? { name: c, type: "unknown" } : c),
61
+ }));
62
+ schemaCache.set(dataSourceId, { schema, ts: Date.now() });
63
+ return schema;
64
+ }
package/dist/setup.js CHANGED
@@ -38,9 +38,9 @@ function getClaudeCodeConfigPath() {
38
38
  return path.join(os.homedir(), ".claude", "settings.json");
39
39
  }
40
40
  export async function main() {
41
- p.intro("redash-mcp 설치 마법사");
41
+ p.intro("redash-mcp setup wizard");
42
42
  const targets = await p.multiselect({
43
- message: "설치 대상을 선택하세요 (스페이스바로 선택, 엔터로 확인)",
43
+ message: "Select installation targets (space to select, enter to confirm)",
44
44
  options: [
45
45
  { value: "desktop", label: "Claude Desktop" },
46
46
  { value: "cli", label: "Claude Code (CLI)" },
@@ -48,32 +48,32 @@ export async function main() {
48
48
  required: true,
49
49
  });
50
50
  if (p.isCancel(targets)) {
51
- p.cancel("설치가 취소되었습니다.");
51
+ p.cancel("Setup cancelled.");
52
52
  process.exit(0);
53
53
  }
54
54
  const redashUrl = await p.text({
55
- message: "Redash URL을 입력하세요",
55
+ message: "Enter your Redash URL",
56
56
  placeholder: "https://redash.example.com",
57
57
  validate(value) {
58
58
  if (!value)
59
- return "URL 입력해주세요.";
59
+ return "URL is required.";
60
60
  if (!value.startsWith("http://") && !value.startsWith("https://"))
61
- return "http:// 또는 https://로 시작해야 합니다.";
61
+ return "Must start with http:// or https://";
62
62
  },
63
63
  });
64
64
  if (p.isCancel(redashUrl)) {
65
- p.cancel("설치가 취소되었습니다.");
65
+ p.cancel("Setup cancelled.");
66
66
  process.exit(0);
67
67
  }
68
68
  const apiKey = await p.text({
69
- message: "Redash API 키를 입력하세요",
69
+ message: "Enter your Redash API key",
70
70
  validate(value) {
71
71
  if (!value)
72
- return "API 키를 입력해주세요.";
72
+ return "API key is required.";
73
73
  },
74
74
  });
75
75
  if (p.isCancel(apiKey)) {
76
- p.cancel("설치가 취소되었습니다.");
76
+ p.cancel("Setup cancelled.");
77
77
  process.exit(0);
78
78
  }
79
79
  const url = redashUrl.replace(/\/$/, "");
@@ -88,16 +88,16 @@ export async function main() {
88
88
  };
89
89
  const s = p.spinner();
90
90
  if (targets.includes("desktop")) {
91
- s.start("Claude Desktop 설정 중...");
91
+ s.start("Configuring Claude Desktop...");
92
92
  setupDesktop(mcpEntry);
93
- s.stop("Claude Desktop 설정 완료");
93
+ s.stop("Claude Desktop configured");
94
94
  }
95
95
  if (targets.includes("cli")) {
96
- s.start("Claude Code (CLI) 설정 중...");
96
+ s.start("Configuring Claude Code (CLI)...");
97
97
  setupClaudeCode(mcpEntry);
98
- s.stop("Claude Code (CLI) 설정 완료");
98
+ s.stop("Claude Code (CLI) configured");
99
99
  }
100
- p.outro("설치가 완료되었습니다. 재시작 사용할 있습니다.");
100
+ p.outro("Setup complete. Restart to start using redash-mcp.");
101
101
  }
102
102
  function setupDesktop(mcpEntry) {
103
103
  const configPath = getDesktopConfigPath();
@@ -108,7 +108,7 @@ function setupDesktop(mcpEntry) {
108
108
  config.mcpServers ??= {};
109
109
  }
110
110
  catch {
111
- throw new Error(`claude_desktop_config.json 파일을 읽을 수 없습니다: ${configPath}`);
111
+ throw new Error(`Failed to read claude_desktop_config.json: ${configPath}`);
112
112
  }
113
113
  }
114
114
  else {
@@ -125,7 +125,7 @@ function setupClaudeCode(mcpEntry) {
125
125
  config = JSON.parse(fs.readFileSync(configPath, "utf8"));
126
126
  }
127
127
  catch {
128
- throw new Error(`Claude Code settings.json 파일을 읽을 수 없습니다: ${configPath}`);
128
+ throw new Error(`Failed to read Claude Code settings.json: ${configPath}`);
129
129
  }
130
130
  }
131
131
  else {