redash-mcp 2.2.1 → 3.0.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/LICENSE +21 -0
- package/README.ko.md +223 -0
- package/README.md +167 -47
- package/dist/bird/complexity.js +87 -0
- package/dist/bird/config.js +73 -0
- package/dist/bird/evaluation.js +184 -0
- package/dist/bird/feedback.js +154 -0
- package/dist/bird/few-shot.js +95 -0
- package/dist/bird/keyword-map.js +56 -0
- package/dist/bird/llm-table-selector.js +59 -0
- package/dist/bird/schema-pruning.js +107 -0
- package/dist/bird/smart-query.js +117 -0
- package/dist/bird/tools.js +288 -0
- package/dist/bird/types.js +1 -0
- package/dist/index.js +101 -163
- package/dist/query-cache.js +0 -2
- package/dist/redash-client.js +64 -0
- package/dist/setup.js +17 -80
- package/dist/sql-guard.js +16 -24
- package/manifest.json +46 -0
- package/package.json +18 -4
- package/.gitattributes +0 -1
- package/docs/sql-safety-guard.md +0 -197
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: "
|
|
20
|
+
version: "3.0.0",
|
|
80
21
|
});
|
|
81
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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", "
|
|
109
|
-
data_source_id: z.number().describe("
|
|
110
|
-
table_name: z.string().describe("
|
|
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(
|
|
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
|
|
134
|
-
data_source_id: z.number().describe("
|
|
135
|
-
query: z.string().describe("
|
|
136
|
-
max_age: z.number().optional().describe("Redash
|
|
137
|
-
max_rows: z.number().optional().default(100).describe("
|
|
138
|
-
format: z.enum(["table", "json"]).optional().default("table").describe("
|
|
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⚠️
|
|
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 = "
|
|
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}
|
|
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⚠️
|
|
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}
|
|
145
|
+
text: `${warningPrefix}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
|
|
208
146
|
},
|
|
209
147
|
],
|
|
210
148
|
};
|
|
211
149
|
});
|
|
212
|
-
|
|
213
|
-
|
|
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(
|
|
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", "
|
|
236
|
-
query_id: z.number().describe("
|
|
237
|
-
max_rows: z.number().optional().default(100).describe("
|
|
238
|
-
format: z.enum(["table", "json"]).optional().default("table").describe("
|
|
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⚠️
|
|
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:
|
|
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", "
|
|
275
|
-
query_id: z.number().describe("
|
|
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", "
|
|
297
|
-
name: z.string().describe("
|
|
298
|
-
query: z.string().describe("SQL
|
|
299
|
-
data_source_id: z.number().describe("
|
|
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", "
|
|
322
|
-
query_id: z.number().describe("
|
|
323
|
-
name: z.string().optional().describe("
|
|
324
|
-
query: z.string().optional().describe("
|
|
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", "
|
|
351
|
-
query_id: z.number().describe("
|
|
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("
|
|
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:
|
|
309
|
+
content: [{ type: "text", text: `Query ${query_id} has been archived.` }],
|
|
372
310
|
};
|
|
373
311
|
});
|
|
374
|
-
|
|
375
|
-
|
|
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(
|
|
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("
|
|
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", "
|
|
438
|
-
dashboard_id: z.number().describe("
|
|
439
|
-
visualization_id: z.number().describe("
|
|
440
|
-
text: z.string().optional().default("").describe("
|
|
441
|
-
width: z.number().optional().default(1).describe("
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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);
|
package/dist/query-cache.js
CHANGED
|
@@ -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
|
+
}
|