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/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: "3.0.0",
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
- const data = await redashFetch("/data_sources");
24
- const sources = data.map((ds) => ({
25
- id: ds.id,
26
- name: ds.name,
27
- type: ds.type,
28
- }));
29
- return {
30
- content: [{ type: "text", text: JSON.stringify(sources, null, 2) }],
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
- const schema = await fetchSchema(data_source_id);
38
- let tables = schema.map((t) => t.name);
39
- if (keyword) {
40
- tables = tables.filter((name) => name.toLowerCase().includes(keyword.toLowerCase()));
41
- }
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")}`;
48
- return {
49
- content: [{ type: "text", text: summary }],
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
- const schema = await fetchSchema(data_source_id);
57
- const tableNames = table_name.split(",").map((n) => n.trim()).filter(Boolean);
58
- const results = [];
59
- for (const name of tableNames) {
60
- let table = schema.find((t) => t.name.toLowerCase() === name.toLowerCase());
61
- if (!table) {
62
- table = schema.find((t) => t.name.toLowerCase().includes(name.toLowerCase()));
63
- }
64
- if (!table) {
65
- results.push(`Table '${name}' not found. Use list_tables to verify the table name.`);
66
- continue;
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
- const cols = (table.columns ?? []).map((c) => typeof c === "string" ? c : `${c.name} (${c.type ?? "unknown"})`).join("\n");
69
- results.push(`[${table.name}]\n${cols}`);
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
- const guard = analyzeQuery(query);
85
- if (guard.blocked) {
86
- return { content: [{ type: "text", text: guard.message }] };
87
- }
88
- const effectiveQuery = guard.modifiedQuery ?? query;
89
- const effectiveMaxAge = max_age ?? DEFAULT_MAX_AGE;
90
- const cached = getCached(data_source_id, effectiveQuery);
91
- if (cached) {
92
- const { rows, columns, warningPrefix } = cached;
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}${cacheNote}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
176
+ text: `${warningPrefix}${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
110
177
  },
111
178
  ],
112
179
  };
113
180
  }
114
- const res = await redashFetch("/query_results", {
115
- method: "POST",
116
- body: JSON.stringify({ data_source_id, query: effectiveQuery, max_age: effectiveMaxAge }),
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
- const effectivePageSize = Math.min(page_size, 100);
156
- const params = new URLSearchParams({
157
- page: String(page),
158
- page_size: String(effectivePageSize),
159
- ...(search ? { q: search } : {}),
160
- });
161
- const data = await redashFetch(`/queries?${params}`);
162
- const queries = data.results.map((q) => ({
163
- id: q.id,
164
- name: q.name,
165
- description: q.description,
166
- data_source_id: q.data_source_id,
167
- updated_at: q.updated_at,
168
- }));
169
- return {
170
- content: [{ type: "text", text: JSON.stringify(queries, null, 2) }],
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
- }, { readOnlyHint: true }, async ({ query_id, max_rows, format }) => {
178
- const res = await redashFetch(`/queries/${query_id}/results`, {
179
- method: "POST",
180
- body: JSON.stringify({}),
181
- });
182
- let result;
183
- if (res.job) {
184
- result = await pollQueryResult(res.job.id);
185
- }
186
- else {
187
- result = res;
188
- }
189
- const qr = result.query_result;
190
- const rows = qr.data.rows;
191
- const columns = qr.data.columns.map((c) => c.name);
192
- const displayRows = rows.slice(0, max_rows);
193
- const truncated = rows.length > max_rows
194
- ? `\n⚠️ Showing ${max_rows} of ${rows.length} rows.`
195
- : "";
196
- let body;
197
- if (format === "json") {
198
- body = JSON.stringify(displayRows, null, 2);
199
- }
200
- else {
201
- body = formatAsMarkdownTable(columns, displayRows);
202
- }
203
- return {
204
- content: [
205
- {
206
- type: "text",
207
- text: `Query ID: ${query_id}\n${rows.length} rows | Columns: ${columns.join(", ")}${truncated}\n\n${body}`,
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
- const data = await redashFetch(`/queries/${query_id}`);
216
- const result = {
217
- id: data.id,
218
- name: data.name,
219
- description: data.description,
220
- query: data.query,
221
- data_source_id: data.data_source_id,
222
- tags: data.tags,
223
- visualizations_count: Array.isArray(data.visualizations) ? data.visualizations.length : 0,
224
- visualizations: Array.isArray(data.visualizations)
225
- ? data.visualizations.map((v) => ({ id: v.id, name: v.name, type: v.type }))
226
- : [],
227
- updated_at: data.updated_at,
228
- user: data.user?.name,
229
- };
230
- return {
231
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
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
- const body = { name, query, data_source_id };
242
- if (description !== undefined)
243
- body.description = description;
244
- if (tags !== undefined)
245
- body.tags = tags;
246
- const data = await redashFetch("/queries", {
247
- method: "POST",
248
- body: JSON.stringify(body),
249
- });
250
- return {
251
- content: [
252
- {
253
- type: "text",
254
- text: JSON.stringify({ id: data.id, name: data.name, created_at: data.created_at }, null, 2),
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
- const body = {};
267
- if (name !== undefined)
268
- body.name = name;
269
- if (query !== undefined)
270
- body.query = query;
271
- if (description !== undefined)
272
- body.description = description;
273
- if (tags !== undefined)
274
- body.tags = tags;
275
- const data = await redashFetch(`/queries/${query_id}`, {
276
- method: "POST",
277
- body: JSON.stringify(body),
278
- });
279
- return {
280
- content: [
281
- {
282
- type: "text",
283
- text: JSON.stringify({ id: data.id, name: data.name, updated_at: data.updated_at }, null, 2),
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
- const data = await redashFetch(`/queries/${query_id}/fork`, {
292
- method: "POST",
293
- body: JSON.stringify({}),
294
- });
295
- return {
296
- content: [
297
- {
298
- type: "text",
299
- text: JSON.stringify({ id: data.id, name: data.name }, null, 2),
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
- await redashFetch(`/queries/${query_id}`, { method: "DELETE" });
308
- return {
309
- content: [{ type: "text", text: `Query ${query_id} has been archived.` }],
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
- const effectivePageSize = Math.min(page_size, 100);
318
- const params = new URLSearchParams({
319
- page: String(page),
320
- page_size: String(effectivePageSize),
321
- ...(search ? { q: search } : {}),
322
- });
323
- const data = await redashFetch(`/dashboards?${params}`);
324
- const results = (data.results ?? data).map((d) => ({
325
- id: d.id,
326
- name: d.name,
327
- slug: d.slug,
328
- created_at: d.created_at,
329
- updated_at: d.updated_at,
330
- }));
331
- return {
332
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
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
- const data = await redashFetch(`/dashboards/${dashboard_id_or_slug}`);
339
- const result = {
340
- id: data.id,
341
- name: data.name,
342
- slug: data.slug,
343
- widgets: Array.isArray(data.widgets)
344
- ? data.widgets.map((w) => ({
345
- id: w.id,
346
- visualization: w.visualization
347
- ? { id: w.visualization.id, name: w.visualization.name, type: w.visualization.type }
348
- : null,
349
- query: w.visualization?.query
350
- ? { id: w.visualization.query.id, name: w.visualization.query.name }
351
- : null,
352
- }))
353
- : [],
354
- };
355
- return {
356
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
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
- const data = await redashFetch("/dashboards", {
363
- method: "POST",
364
- body: JSON.stringify({ name }),
365
- });
366
- return {
367
- content: [
368
- {
369
- type: "text",
370
- text: JSON.stringify({ id: data.id, name: data.name, slug: data.slug }, null, 2),
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.number().optional().default(1).describe("Widget width (1 or 2)"),
467
+ width: z.union([z.literal(1), z.literal(2)]).optional().default(1).describe("Widget width (1 = half, 2 = full)"),
380
468
  }, { destructiveHint: true }, async ({ dashboard_id, visualization_id, text, width }) => {
381
- const data = await redashFetch("/widgets", {
382
- method: "POST",
383
- body: JSON.stringify({
384
- dashboard_id,
385
- visualization_id,
386
- text,
387
- width,
388
- options: {},
389
- }),
390
- });
391
- return {
392
- content: [
393
- {
394
- type: "text",
395
- text: JSON.stringify({ id: data.id, dashboard_id: data.dashboard_id }, null, 2),
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
- const data = await redashFetch("/alerts");
402
- const alerts = Array.isArray(data) ? data : [];
403
- const result = alerts.map((alert) => ({
404
- id: alert.id,
405
- name: alert.name,
406
- state: alert.state,
407
- last_triggered_at: alert.last_triggered_at,
408
- query: alert.query ? { id: alert.query.id, name: alert.query.name } : null,
409
- options: alert.options,
410
- }));
411
- return {
412
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
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
- const alert = await redashFetch(`/alerts/${alert_id}`);
419
- const result = {
420
- id: alert.id,
421
- name: alert.name,
422
- state: alert.state,
423
- last_triggered_at: alert.last_triggered_at,
424
- query: alert.query
425
- ? {
426
- id: alert.query.id,
427
- name: alert.query.name,
428
- description: alert.query.description,
429
- data_source_id: alert.query.data_source_id,
430
- }
431
- : null,
432
- options: alert.options,
433
- };
434
- return {
435
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
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
- const data = await redashFetch("/alerts", {
447
- method: "POST",
448
- body: JSON.stringify({
449
- name,
450
- query_id,
451
- rearm: rearm ?? 0,
452
- options: { column, op, value },
453
- }),
454
- });
455
- return {
456
- content: [
457
- {
458
- type: "text",
459
- text: JSON.stringify({ id: data.id, name: data.name }, null, 2),
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();