redash-mcp 3.0.3 → 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 +4 -0
- package/README.md +4 -0
- package/dist/bird/evaluation.js +2 -14
- package/dist/bird/feedback.js +1 -2
- package/dist/bird/few-shot.js +1 -1
- package/dist/bird/smart-query.js +7 -2
- package/dist/bird/tools.js +225 -194
- package/dist/index.js +434 -326
- package/dist/query-cache.js +15 -0
- package/dist/redash-client.js +71 -11
- package/dist/tool-error.js +40 -0
- package/package.json +10 -7
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";
|
|
@@ -6,6 +9,7 @@ import { analyzeQuery } from "./sql-guard.js";
|
|
|
6
9
|
import { getCached, setCached } from "./query-cache.js";
|
|
7
10
|
import { REDASH_URL, REDASH_API_KEY, redashFetch, pollQueryResult, formatAsMarkdownTable, fetchSchema } from "./redash-client.js";
|
|
8
11
|
import { registerBirdTools } from "./bird/tools.js";
|
|
12
|
+
import { handleToolError } from "./tool-error.js";
|
|
9
13
|
if (process.argv[2] === "setup") {
|
|
10
14
|
const { main } = await import("./setup.js");
|
|
11
15
|
await main();
|
|
@@ -15,62 +19,88 @@ if (!REDASH_URL || !REDASH_API_KEY) {
|
|
|
15
19
|
console.error("REDASH_URL and REDASH_API_KEY environment variables are required");
|
|
16
20
|
process.exit(1);
|
|
17
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
|
+
}
|
|
18
33
|
const server = new McpServer({
|
|
19
34
|
name: "redash-mcp",
|
|
20
|
-
version:
|
|
35
|
+
version: readPackageVersion(),
|
|
21
36
|
});
|
|
22
37
|
server.tool("list_data_sources", "List connected data sources (id, name, type). Call this first to get data_source_id.", {}, { readOnlyHint: true }, async () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
try {
|
|
39
|
+
const data = await redashFetch("/data_sources");
|
|
40
|
+
const sources = data.map((ds) => ({
|
|
41
|
+
id: ds.id,
|
|
42
|
+
name: ds.name,
|
|
43
|
+
type: ds.type,
|
|
44
|
+
}));
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: JSON.stringify(sources, null, 2) }],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return handleToolError("list_data_sources", error);
|
|
51
|
+
}
|
|
32
52
|
});
|
|
33
53
|
server.tool("list_tables", "List tables in a data source. Use keyword to filter by name. Verify table names here before writing SQL.", {
|
|
34
54
|
data_source_id: z.number().describe("Data source ID from list_data_sources"),
|
|
35
55
|
keyword: z.string().optional().describe("Filter keyword for table names (e.g., 'user', 'order')"),
|
|
36
56
|
}, { readOnlyHint: true }, async ({ data_source_id, keyword }) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
try {
|
|
58
|
+
const schema = await fetchSchema(data_source_id);
|
|
59
|
+
let tables = schema.map((t) => t.name);
|
|
60
|
+
if (keyword) {
|
|
61
|
+
tables = tables.filter((name) => name.toLowerCase().includes(keyword.toLowerCase()));
|
|
62
|
+
}
|
|
63
|
+
const total = tables.length;
|
|
64
|
+
const MAX_TABLES = 200;
|
|
65
|
+
const truncated = tables.length > MAX_TABLES;
|
|
66
|
+
if (truncated)
|
|
67
|
+
tables = tables.slice(0, MAX_TABLES);
|
|
68
|
+
const summary = `${total} tables${keyword ? ` (matching '${keyword}')` : ""}${truncated ? ` (showing first ${MAX_TABLES}, use keyword to filter)` : ""}\n\n${tables.join("\n")}`;
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: summary }],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return handleToolError("list_tables", error);
|
|
75
|
+
}
|
|
51
76
|
});
|
|
52
77
|
server.tool("get_table_columns", "Get column names and types for one or more tables (comma-separated). Verify columns before writing SQL.", {
|
|
53
78
|
data_source_id: z.number().describe("Data source ID from list_data_sources"),
|
|
54
79
|
table_name: z.string().describe("Table name(s), comma-separated (e.g., 'users' or 'users,orders')"),
|
|
55
80
|
}, { readOnlyHint: true }, async ({ data_source_id, table_name }) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
try {
|
|
82
|
+
const schema = await fetchSchema(data_source_id);
|
|
83
|
+
const tableNames = table_name.split(",").map((n) => n.trim()).filter(Boolean);
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const name of tableNames) {
|
|
86
|
+
let table = schema.find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
87
|
+
if (!table) {
|
|
88
|
+
table = schema.find((t) => t.name.toLowerCase().includes(name.toLowerCase()));
|
|
89
|
+
}
|
|
90
|
+
if (!table) {
|
|
91
|
+
results.push(`Table '${name}' not found. Use list_tables to verify the table name.`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const cols = (table.columns ?? []).map((c) => typeof c === "string" ? c : `${c.name} (${c.type ?? "unknown"})`).join("\n");
|
|
95
|
+
results.push(`[${table.name}]\n${cols}`);
|
|
67
96
|
}
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: results.join("\n\n") }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return handleToolError("get_table_columns", error);
|
|
70
103
|
}
|
|
71
|
-
return {
|
|
72
|
-
content: [{ type: "text", text: results.join("\n\n") }],
|
|
73
|
-
};
|
|
74
104
|
});
|
|
75
105
|
const DEFAULT_MAX_AGE = parseInt(process.env.REDASH_DEFAULT_MAX_AGE ?? "0", 10) || 0;
|
|
76
106
|
server.tool("run_query", "Execute SQL against a data source and return results. Check schema with list_tables and get_table_columns first.", {
|
|
@@ -81,15 +111,53 @@ server.tool("run_query", "Execute SQL against a data source and return results.
|
|
|
81
111
|
format: z.enum(["table", "json"]).optional().default("table").describe("Output format: table (markdown) or json"),
|
|
82
112
|
timeout_secs: z.number().optional().default(30).describe("Query execution timeout in seconds"),
|
|
83
113
|
}, { readOnlyHint: true }, async ({ data_source_id, query, max_age, max_rows, format, timeout_secs }) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
try {
|
|
115
|
+
const guard = analyzeQuery(query);
|
|
116
|
+
if (guard.blocked) {
|
|
117
|
+
return { content: [{ type: "text", text: guard.message }] };
|
|
118
|
+
}
|
|
119
|
+
const effectiveQuery = guard.modifiedQuery ?? query;
|
|
120
|
+
const effectiveMaxAge = max_age ?? DEFAULT_MAX_AGE;
|
|
121
|
+
const cached = getCached(data_source_id, effectiveQuery);
|
|
122
|
+
if (cached) {
|
|
123
|
+
const { rows, columns, warningPrefix } = cached;
|
|
124
|
+
const displayRows = rows.slice(0, max_rows);
|
|
125
|
+
const truncated = rows.length > max_rows
|
|
126
|
+
? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
|
|
127
|
+
: "";
|
|
128
|
+
let body;
|
|
129
|
+
if (format === "json") {
|
|
130
|
+
body = JSON.stringify(displayRows, null, 2);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
body = formatAsMarkdownTable(columns, displayRows);
|
|
134
|
+
}
|
|
135
|
+
const cacheNote = "Returned from MCP cache.\n\n";
|
|
136
|
+
return {
|
|
137
|
+
content: [
|
|
138
|
+
{
|
|
139
|
+
type: "text",
|
|
140
|
+
text: `${warningPrefix}${cacheNote}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const res = await redashFetch("/query_results", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: JSON.stringify({ data_source_id, query: effectiveQuery, max_age: effectiveMaxAge }),
|
|
148
|
+
});
|
|
149
|
+
let result;
|
|
150
|
+
if (res.job) {
|
|
151
|
+
result = await pollQueryResult(res.job.id, timeout_secs);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
result = res;
|
|
155
|
+
}
|
|
156
|
+
const qr = result.query_result;
|
|
157
|
+
const rows = qr.data.rows;
|
|
158
|
+
const columns = qr.data.columns.map((c) => c.name);
|
|
159
|
+
const warningPrefix = guard.message ? `${guard.message}\n\n` : "";
|
|
160
|
+
setCached(data_source_id, effectiveQuery, { rows, columns, warningPrefix });
|
|
93
161
|
const displayRows = rows.slice(0, max_rows);
|
|
94
162
|
const truncated = rows.length > max_rows
|
|
95
163
|
? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
|
|
@@ -101,135 +169,119 @@ server.tool("run_query", "Execute SQL against a data source and return results.
|
|
|
101
169
|
else {
|
|
102
170
|
body = formatAsMarkdownTable(columns, displayRows);
|
|
103
171
|
}
|
|
104
|
-
const cacheNote = "Returned from MCP cache.\n\n";
|
|
105
172
|
return {
|
|
106
173
|
content: [
|
|
107
174
|
{
|
|
108
175
|
type: "text",
|
|
109
|
-
text: `${warningPrefix}${
|
|
176
|
+
text: `${warningPrefix}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
|
|
110
177
|
},
|
|
111
178
|
],
|
|
112
179
|
};
|
|
113
180
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
});
|
|
118
|
-
let result;
|
|
119
|
-
if (res.job) {
|
|
120
|
-
result = await pollQueryResult(res.job.id, timeout_secs);
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
result = res;
|
|
124
|
-
}
|
|
125
|
-
const qr = result.query_result;
|
|
126
|
-
const rows = qr.data.rows;
|
|
127
|
-
const columns = qr.data.columns.map((c) => c.name);
|
|
128
|
-
const warningPrefix = guard.message ? `${guard.message}\n\n` : "";
|
|
129
|
-
setCached(data_source_id, effectiveQuery, { rows, columns, warningPrefix });
|
|
130
|
-
const displayRows = rows.slice(0, max_rows);
|
|
131
|
-
const truncated = rows.length > max_rows
|
|
132
|
-
? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
|
|
133
|
-
: "";
|
|
134
|
-
let body;
|
|
135
|
-
if (format === "json") {
|
|
136
|
-
body = JSON.stringify(displayRows, null, 2);
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
body = formatAsMarkdownTable(columns, displayRows);
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
content: [
|
|
143
|
-
{
|
|
144
|
-
type: "text",
|
|
145
|
-
text: `${warningPrefix}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
|
|
146
|
-
},
|
|
147
|
-
],
|
|
148
|
-
};
|
|
181
|
+
catch (error) {
|
|
182
|
+
return handleToolError("run_query", error);
|
|
183
|
+
}
|
|
149
184
|
});
|
|
150
185
|
server.tool("list_queries", "List saved queries in Redash.", {
|
|
151
186
|
search: z.string().optional().describe("Search keyword"),
|
|
152
187
|
page: z.number().optional().default(1),
|
|
153
188
|
page_size: z.number().optional().default(20).describe("Page size (max 100)"),
|
|
154
189
|
}, { readOnlyHint: true }, async ({ search, page, page_size }) => {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
try {
|
|
191
|
+
const effectivePageSize = Math.max(1, Math.min(page_size, 100));
|
|
192
|
+
const effectivePage = Math.max(1, page);
|
|
193
|
+
const params = new URLSearchParams({
|
|
194
|
+
page: String(effectivePage),
|
|
195
|
+
page_size: String(effectivePageSize),
|
|
196
|
+
...(search ? { q: search } : {}),
|
|
197
|
+
});
|
|
198
|
+
const data = await redashFetch(`/queries?${params}`);
|
|
199
|
+
const queries = data.results.map((q) => ({
|
|
200
|
+
id: q.id,
|
|
201
|
+
name: q.name,
|
|
202
|
+
description: q.description,
|
|
203
|
+
data_source_id: q.data_source_id,
|
|
204
|
+
updated_at: q.updated_at,
|
|
205
|
+
}));
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: JSON.stringify(queries, null, 2) }],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return handleToolError("list_queries", error);
|
|
212
|
+
}
|
|
172
213
|
});
|
|
173
214
|
server.tool("get_query_result", "Execute a saved query by ID and return results.", {
|
|
174
215
|
query_id: z.number().describe("Saved query ID (from list_queries)"),
|
|
175
216
|
max_rows: z.number().optional().default(100).describe("Max rows to return (default 100)"),
|
|
176
217
|
format: z.enum(["table", "json"]).optional().default("table").describe("Output format: table (markdown) or json"),
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
result
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
body
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 }) => {
|
|
220
|
+
try {
|
|
221
|
+
const res = await redashFetch(`/queries/${query_id}/results`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
body: JSON.stringify({}),
|
|
224
|
+
});
|
|
225
|
+
let result;
|
|
226
|
+
if (res.job) {
|
|
227
|
+
result = await pollQueryResult(res.job.id, timeout_secs);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
result = res;
|
|
231
|
+
}
|
|
232
|
+
const qr = result.query_result;
|
|
233
|
+
const rows = qr.data.rows;
|
|
234
|
+
const columns = qr.data.columns.map((c) => c.name);
|
|
235
|
+
const displayRows = rows.slice(0, max_rows);
|
|
236
|
+
const truncated = rows.length > max_rows
|
|
237
|
+
? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
|
|
238
|
+
: "";
|
|
239
|
+
let body;
|
|
240
|
+
if (format === "json") {
|
|
241
|
+
body = JSON.stringify(displayRows, null, 2);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
body = formatAsMarkdownTable(columns, displayRows);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: "text",
|
|
250
|
+
text: `Query ID: ${query_id}\n${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return handleToolError("get_query_result", error);
|
|
257
|
+
}
|
|
211
258
|
});
|
|
212
259
|
server.tool("get_query", "Get saved query details (SQL, visualizations, tags).", {
|
|
213
260
|
query_id: z.number().describe("Query ID (from list_queries)"),
|
|
214
261
|
}, { readOnlyHint: true }, async ({ query_id }) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
262
|
+
try {
|
|
263
|
+
const data = await redashFetch(`/queries/${query_id}`);
|
|
264
|
+
const result = {
|
|
265
|
+
id: data.id,
|
|
266
|
+
name: data.name,
|
|
267
|
+
description: data.description,
|
|
268
|
+
query: data.query,
|
|
269
|
+
data_source_id: data.data_source_id,
|
|
270
|
+
tags: data.tags,
|
|
271
|
+
visualizations_count: Array.isArray(data.visualizations) ? data.visualizations.length : 0,
|
|
272
|
+
visualizations: Array.isArray(data.visualizations)
|
|
273
|
+
? data.visualizations.map((v) => ({ id: v.id, name: v.name, type: v.type }))
|
|
274
|
+
: [],
|
|
275
|
+
updated_at: data.updated_at,
|
|
276
|
+
user: data.user?.name,
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
return handleToolError("get_query", error);
|
|
284
|
+
}
|
|
233
285
|
});
|
|
234
286
|
server.tool("create_query", "Save a new query to Redash.", {
|
|
235
287
|
name: z.string().describe("Query name"),
|
|
@@ -238,23 +290,28 @@ server.tool("create_query", "Save a new query to Redash.", {
|
|
|
238
290
|
description: z.string().optional().describe("Query description"),
|
|
239
291
|
tags: z.array(z.string()).optional().describe("Tags"),
|
|
240
292
|
}, { destructiveHint: true }, async ({ name, query, data_source_id, description, tags }) => {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
293
|
+
try {
|
|
294
|
+
const body = { name, query, data_source_id };
|
|
295
|
+
if (description !== undefined)
|
|
296
|
+
body.description = description;
|
|
297
|
+
if (tags !== undefined)
|
|
298
|
+
body.tags = tags;
|
|
299
|
+
const data = await redashFetch("/queries", {
|
|
300
|
+
method: "POST",
|
|
301
|
+
body: JSON.stringify(body),
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
content: [
|
|
305
|
+
{
|
|
306
|
+
type: "text",
|
|
307
|
+
text: JSON.stringify({ id: data.id, name: data.name, created_at: data.created_at }, null, 2),
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
return handleToolError("create_query", error);
|
|
314
|
+
}
|
|
258
315
|
});
|
|
259
316
|
server.tool("update_query", "Update a saved query's name, SQL, description, or tags.", {
|
|
260
317
|
query_id: z.number().describe("Query ID to update"),
|
|
@@ -263,177 +320,223 @@ server.tool("update_query", "Update a saved query's name, SQL, description, or t
|
|
|
263
320
|
description: z.string().optional().describe("New description"),
|
|
264
321
|
tags: z.array(z.string()).optional().describe("New tags"),
|
|
265
322
|
}, { destructiveHint: true }, async ({ query_id, name, query, description, tags }) => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
323
|
+
try {
|
|
324
|
+
const body = {};
|
|
325
|
+
if (name !== undefined)
|
|
326
|
+
body.name = name;
|
|
327
|
+
if (query !== undefined)
|
|
328
|
+
body.query = query;
|
|
329
|
+
if (description !== undefined)
|
|
330
|
+
body.description = description;
|
|
331
|
+
if (tags !== undefined)
|
|
332
|
+
body.tags = tags;
|
|
333
|
+
const data = await redashFetch(`/queries/${query_id}`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
body: JSON.stringify(body),
|
|
336
|
+
});
|
|
337
|
+
return {
|
|
338
|
+
content: [
|
|
339
|
+
{
|
|
340
|
+
type: "text",
|
|
341
|
+
text: JSON.stringify({ id: data.id, name: data.name, updated_at: data.updated_at }, null, 2),
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
return handleToolError("update_query", error);
|
|
348
|
+
}
|
|
287
349
|
});
|
|
288
350
|
server.tool("fork_query", "Fork (duplicate) an existing query.", {
|
|
289
351
|
query_id: z.number().describe("Query ID to fork"),
|
|
290
352
|
}, { destructiveHint: true }, async ({ query_id }) => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
353
|
+
try {
|
|
354
|
+
const data = await redashFetch(`/queries/${query_id}/fork`, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
body: JSON.stringify({}),
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: "text",
|
|
362
|
+
text: JSON.stringify({ id: data.id, name: data.name }, null, 2),
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
return handleToolError("fork_query", error);
|
|
369
|
+
}
|
|
303
370
|
});
|
|
304
371
|
server.tool("archive_query", "Archive (delete) a query. This action is irreversible.", {
|
|
305
372
|
query_id: z.number().describe("Query ID to archive"),
|
|
306
373
|
}, { destructiveHint: true }, async ({ query_id }) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
374
|
+
try {
|
|
375
|
+
await redashFetch(`/queries/${query_id}`, { method: "DELETE" });
|
|
376
|
+
return {
|
|
377
|
+
content: [{ type: "text", text: `Query ${query_id} has been archived.` }],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return handleToolError("archive_query", error);
|
|
382
|
+
}
|
|
311
383
|
});
|
|
312
384
|
server.tool("list_dashboards", "List dashboards in Redash.", {
|
|
313
385
|
search: z.string().optional().describe("Search keyword"),
|
|
314
386
|
page: z.number().optional().default(1),
|
|
315
387
|
page_size: z.number().optional().default(20).describe("Page size (max 100)"),
|
|
316
388
|
}, { readOnlyHint: true }, async ({ search, page, page_size }) => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
389
|
+
try {
|
|
390
|
+
const effectivePageSize = Math.max(1, Math.min(page_size, 100));
|
|
391
|
+
const effectivePage = Math.max(1, page);
|
|
392
|
+
const params = new URLSearchParams({
|
|
393
|
+
page: String(effectivePage),
|
|
394
|
+
page_size: String(effectivePageSize),
|
|
395
|
+
...(search ? { q: search } : {}),
|
|
396
|
+
});
|
|
397
|
+
const data = await redashFetch(`/dashboards?${params}`);
|
|
398
|
+
const results = (data.results ?? data).map((d) => ({
|
|
399
|
+
id: d.id,
|
|
400
|
+
name: d.name,
|
|
401
|
+
slug: d.slug,
|
|
402
|
+
created_at: d.created_at,
|
|
403
|
+
updated_at: d.updated_at,
|
|
404
|
+
}));
|
|
405
|
+
return {
|
|
406
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
return handleToolError("list_dashboards", error);
|
|
411
|
+
}
|
|
334
412
|
});
|
|
335
413
|
server.tool("get_dashboard", "Get dashboard details including widgets and visualizations.", {
|
|
336
414
|
dashboard_id_or_slug: z.string().describe("Dashboard ID or slug (from list_dashboards)"),
|
|
337
415
|
}, { readOnlyHint: true }, async ({ dashboard_id_or_slug }) => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
416
|
+
try {
|
|
417
|
+
const data = await redashFetch(`/dashboards/${dashboard_id_or_slug}`);
|
|
418
|
+
const result = {
|
|
419
|
+
id: data.id,
|
|
420
|
+
name: data.name,
|
|
421
|
+
slug: data.slug,
|
|
422
|
+
widgets: Array.isArray(data.widgets)
|
|
423
|
+
? data.widgets.map((w) => ({
|
|
424
|
+
id: w.id,
|
|
425
|
+
visualization: w.visualization
|
|
426
|
+
? { id: w.visualization.id, name: w.visualization.name, type: w.visualization.type }
|
|
427
|
+
: null,
|
|
428
|
+
query: w.visualization?.query
|
|
429
|
+
? { id: w.visualization.query.id, name: w.visualization.query.name }
|
|
430
|
+
: null,
|
|
431
|
+
}))
|
|
432
|
+
: [],
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
return handleToolError("get_dashboard", error);
|
|
440
|
+
}
|
|
358
441
|
});
|
|
359
442
|
server.tool("create_dashboard", "Create a new dashboard.", {
|
|
360
443
|
name: z.string().describe("Dashboard name"),
|
|
361
444
|
}, { destructiveHint: true }, async ({ name }) => {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
445
|
+
try {
|
|
446
|
+
const data = await redashFetch("/dashboards", {
|
|
447
|
+
method: "POST",
|
|
448
|
+
body: JSON.stringify({ name }),
|
|
449
|
+
});
|
|
450
|
+
return {
|
|
451
|
+
content: [
|
|
452
|
+
{
|
|
453
|
+
type: "text",
|
|
454
|
+
text: JSON.stringify({ id: data.id, name: data.name, slug: data.slug }, null, 2),
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
return handleToolError("create_dashboard", error);
|
|
461
|
+
}
|
|
374
462
|
});
|
|
375
463
|
server.tool("add_widget", "Add a visualization widget to a dashboard. Get visualization_id from get_query.", {
|
|
376
464
|
dashboard_id: z.number().describe("Dashboard ID"),
|
|
377
465
|
visualization_id: z.number().describe("Visualization ID (from get_query's visualizations)"),
|
|
378
466
|
text: z.string().optional().default("").describe("Widget text"),
|
|
379
|
-
width: z.
|
|
467
|
+
width: z.union([z.literal(1), z.literal(2)]).optional().default(1).describe("Widget width (1 = half, 2 = full)"),
|
|
380
468
|
}, { destructiveHint: true }, async ({ dashboard_id, visualization_id, text, width }) => {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
469
|
+
try {
|
|
470
|
+
const data = await redashFetch("/widgets", {
|
|
471
|
+
method: "POST",
|
|
472
|
+
body: JSON.stringify({
|
|
473
|
+
dashboard_id,
|
|
474
|
+
visualization_id,
|
|
475
|
+
text,
|
|
476
|
+
width,
|
|
477
|
+
options: {},
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: JSON.stringify({ id: data.id, dashboard_id: data.dashboard_id }, null, 2),
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
return handleToolError("add_widget", error);
|
|
491
|
+
}
|
|
399
492
|
});
|
|
400
493
|
server.tool("list_alerts", "List alerts in Redash.", {}, { readOnlyHint: true }, async () => {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
494
|
+
try {
|
|
495
|
+
const data = await redashFetch("/alerts");
|
|
496
|
+
const alerts = Array.isArray(data) ? data : [];
|
|
497
|
+
const result = alerts.map((alert) => ({
|
|
498
|
+
id: alert.id,
|
|
499
|
+
name: alert.name,
|
|
500
|
+
state: alert.state,
|
|
501
|
+
last_triggered_at: alert.last_triggered_at,
|
|
502
|
+
query: alert.query ? { id: alert.query.id, name: alert.query.name } : null,
|
|
503
|
+
options: alert.options,
|
|
504
|
+
}));
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
return handleToolError("list_alerts", error);
|
|
511
|
+
}
|
|
414
512
|
});
|
|
415
513
|
server.tool("get_alert", "Get alert details (threshold, linked query, state).", {
|
|
416
514
|
alert_id: z.number().describe("Alert ID (from list_alerts)"),
|
|
417
515
|
}, { readOnlyHint: true }, async ({ alert_id }) => {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
516
|
+
try {
|
|
517
|
+
const alert = await redashFetch(`/alerts/${alert_id}`);
|
|
518
|
+
const result = {
|
|
519
|
+
id: alert.id,
|
|
520
|
+
name: alert.name,
|
|
521
|
+
state: alert.state,
|
|
522
|
+
last_triggered_at: alert.last_triggered_at,
|
|
523
|
+
query: alert.query
|
|
524
|
+
? {
|
|
525
|
+
id: alert.query.id,
|
|
526
|
+
name: alert.query.name,
|
|
527
|
+
description: alert.query.description,
|
|
528
|
+
data_source_id: alert.query.data_source_id,
|
|
529
|
+
}
|
|
530
|
+
: null,
|
|
531
|
+
options: alert.options,
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
return handleToolError("get_alert", error);
|
|
539
|
+
}
|
|
437
540
|
});
|
|
438
541
|
server.tool("create_alert", "Create a new alert. Triggers when a query result column crosses a threshold.", {
|
|
439
542
|
name: z.string().describe("Alert name"),
|
|
@@ -443,23 +546,28 @@ server.tool("create_alert", "Create a new alert. Triggers when a query result co
|
|
|
443
546
|
value: z.number().describe("Threshold value"),
|
|
444
547
|
rearm: z.number().optional().default(0).describe("Rearm interval in seconds (0 = fire once)"),
|
|
445
548
|
}, { destructiveHint: true }, async ({ name, query_id, column, op, value, rearm }) => {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
549
|
+
try {
|
|
550
|
+
const data = await redashFetch("/alerts", {
|
|
551
|
+
method: "POST",
|
|
552
|
+
body: JSON.stringify({
|
|
553
|
+
name,
|
|
554
|
+
query_id,
|
|
555
|
+
rearm: rearm ?? 0,
|
|
556
|
+
options: { column, op, value },
|
|
557
|
+
}),
|
|
558
|
+
});
|
|
559
|
+
return {
|
|
560
|
+
content: [
|
|
561
|
+
{
|
|
562
|
+
type: "text",
|
|
563
|
+
text: JSON.stringify({ id: data.id, name: data.name }, null, 2),
|
|
564
|
+
},
|
|
565
|
+
],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
return handleToolError("create_alert", error);
|
|
570
|
+
}
|
|
463
571
|
});
|
|
464
572
|
registerBirdTools(server);
|
|
465
573
|
const transport = new StdioServerTransport();
|