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