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/query-cache.js
CHANGED
|
@@ -25,6 +25,17 @@ function makeCacheKey(dataSourceId, sql) {
|
|
|
25
25
|
function roughSize(obj) {
|
|
26
26
|
return JSON.stringify(obj).length * 2;
|
|
27
27
|
}
|
|
28
|
+
function purgeExpired(ttl) {
|
|
29
|
+
if (ttl === 0)
|
|
30
|
+
return;
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [key, entry] of cache) {
|
|
33
|
+
if (now - entry.ts > ttl) {
|
|
34
|
+
totalSizeBytes -= entry.size;
|
|
35
|
+
cache.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
28
39
|
export function getCached(dataSourceId, sql) {
|
|
29
40
|
const ttl = getCacheTtlMs();
|
|
30
41
|
if (ttl === 0)
|
|
@@ -38,6 +49,9 @@ export function getCached(dataSourceId, sql) {
|
|
|
38
49
|
cache.delete(key);
|
|
39
50
|
return null;
|
|
40
51
|
}
|
|
52
|
+
// Touch entry to maintain LRU order via insertion-order Map semantics.
|
|
53
|
+
cache.delete(key);
|
|
54
|
+
cache.set(key, entry);
|
|
41
55
|
return entry.result;
|
|
42
56
|
}
|
|
43
57
|
export function setCached(dataSourceId, sql, result) {
|
|
@@ -48,6 +62,7 @@ export function setCached(dataSourceId, sql, result) {
|
|
|
48
62
|
const size = roughSize(result);
|
|
49
63
|
if (size > maxSize * 0.2)
|
|
50
64
|
return;
|
|
65
|
+
purgeExpired(ttl);
|
|
51
66
|
while (totalSizeBytes + size > maxSize && cache.size > 0) {
|
|
52
67
|
const oldestKey = cache.keys().next().value;
|
|
53
68
|
if (!oldestKey)
|
package/dist/redash-client.js
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
|
-
|
|
1
|
+
function validateRedashUrl(raw) {
|
|
2
|
+
if (raw === undefined)
|
|
3
|
+
return undefined;
|
|
4
|
+
const trimmed = raw.trim();
|
|
5
|
+
if (trimmed.length === 0)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (/[\n\r]/.test(raw)) {
|
|
8
|
+
throw new Error("REDASH_URL must not contain newlines");
|
|
9
|
+
}
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = new URL(trimmed);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`REDASH_URL is not a valid URL: ${raw}`);
|
|
16
|
+
}
|
|
17
|
+
if (parsed.username || parsed.password) {
|
|
18
|
+
throw new Error("REDASH_URL must not contain credentials");
|
|
19
|
+
}
|
|
20
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
21
|
+
throw new Error(`REDASH_URL must use http or https scheme, got: ${parsed.protocol}`);
|
|
22
|
+
}
|
|
23
|
+
return trimmed.replace(/\/$/, "");
|
|
24
|
+
}
|
|
25
|
+
const REDASH_URL = validateRedashUrl(process.env.REDASH_URL);
|
|
2
26
|
const REDASH_API_KEY = process.env.REDASH_API_KEY;
|
|
27
|
+
const HTTP_TIMEOUT_MS = (() => {
|
|
28
|
+
const raw = parseInt(process.env.REDASH_HTTP_TIMEOUT_SECS ?? "30", 10);
|
|
29
|
+
return (isNaN(raw) || raw <= 0 ? 30 : raw) * 1000;
|
|
30
|
+
})();
|
|
3
31
|
export { REDASH_URL, REDASH_API_KEY };
|
|
4
32
|
export async function redashFetch(path, options) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
...options
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
35
|
+
let res;
|
|
36
|
+
try {
|
|
37
|
+
res = await fetch(`${REDASH_URL}/api${path}`, {
|
|
38
|
+
...options,
|
|
39
|
+
signal: options?.signal ?? controller.signal,
|
|
40
|
+
headers: {
|
|
41
|
+
"Authorization": `Key ${REDASH_API_KEY}`,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
...options?.headers,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err?.name === "AbortError") {
|
|
49
|
+
throw new Error(`Redash request timed out after ${HTTP_TIMEOUT_MS / 1000}s`);
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
13
56
|
if (!res.ok) {
|
|
14
57
|
let hint = "";
|
|
15
58
|
if (res.status === 401)
|
|
@@ -26,7 +69,9 @@ export async function redashFetch(path, options) {
|
|
|
26
69
|
return res.json();
|
|
27
70
|
}
|
|
28
71
|
export async function pollQueryResult(jobId, timeoutSecs = 30) {
|
|
29
|
-
|
|
72
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
73
|
+
let delayMs = 250;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
30
75
|
const job = await redashFetch(`/jobs/${jobId}`);
|
|
31
76
|
if (job.job.status === 3) {
|
|
32
77
|
return await redashFetch(`/query_results/${job.job.query_result_id}`);
|
|
@@ -34,7 +79,11 @@ export async function pollQueryResult(jobId, timeoutSecs = 30) {
|
|
|
34
79
|
if (job.job.status === 4) {
|
|
35
80
|
throw new Error(`Query failed: ${job.job.error}`);
|
|
36
81
|
}
|
|
37
|
-
|
|
82
|
+
const remaining = deadline - Date.now();
|
|
83
|
+
if (remaining <= 0)
|
|
84
|
+
break;
|
|
85
|
+
await new Promise((r) => setTimeout(r, Math.min(delayMs, remaining)));
|
|
86
|
+
delayMs = Math.min(delayMs * 2, 2000);
|
|
38
87
|
}
|
|
39
88
|
throw new Error(`Query timed out after ${timeoutSecs}s`);
|
|
40
89
|
}
|
|
@@ -49,16 +98,27 @@ export function formatAsMarkdownTable(columns, rows) {
|
|
|
49
98
|
}
|
|
50
99
|
const schemaCache = new Map();
|
|
51
100
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
101
|
+
const SCHEMA_CACHE_MAX_ENTRIES = 32;
|
|
52
102
|
export async function fetchSchema(dataSourceId) {
|
|
53
103
|
const cached = schemaCache.get(dataSourceId);
|
|
54
104
|
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
|
|
105
|
+
schemaCache.delete(dataSourceId);
|
|
106
|
+
schemaCache.set(dataSourceId, cached);
|
|
55
107
|
return cached.schema;
|
|
56
108
|
}
|
|
109
|
+
if (cached)
|
|
110
|
+
schemaCache.delete(dataSourceId);
|
|
57
111
|
const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
|
|
58
112
|
const schema = (result.schema ?? []).map((table) => ({
|
|
59
113
|
...table,
|
|
60
114
|
columns: (table.columns ?? []).map((c) => typeof c === "string" ? { name: c, type: "unknown" } : c),
|
|
61
115
|
}));
|
|
116
|
+
while (schemaCache.size >= SCHEMA_CACHE_MAX_ENTRIES) {
|
|
117
|
+
const oldest = schemaCache.keys().next().value;
|
|
118
|
+
if (oldest === undefined)
|
|
119
|
+
break;
|
|
120
|
+
schemaCache.delete(oldest);
|
|
121
|
+
}
|
|
62
122
|
schemaCache.set(dataSourceId, { schema, ts: Date.now() });
|
|
63
123
|
return schema;
|
|
64
124
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared error handler for MCP tool callbacks.
|
|
3
|
+
* Logs the full error to stderr for debugging, then returns a
|
|
4
|
+
* user-friendly MCP error response without leaking internal details.
|
|
5
|
+
*/
|
|
6
|
+
export function handleToolError(toolName, error) {
|
|
7
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
8
|
+
// Log full detail to stderr so operators can debug.
|
|
9
|
+
console.error(`[${toolName}] ${detail}`);
|
|
10
|
+
// Derive a safe, user-facing message.
|
|
11
|
+
let userMessage;
|
|
12
|
+
if (detail.includes("Query timed out")) {
|
|
13
|
+
userMessage = "The query timed out. Try simplifying the query or increasing timeout_secs.";
|
|
14
|
+
}
|
|
15
|
+
else if (detail.includes("Query failed")) {
|
|
16
|
+
userMessage = "The query execution failed. Please check the SQL syntax and try again.";
|
|
17
|
+
}
|
|
18
|
+
else if (detail.includes("401") || detail.includes("Check your REDASH_API_KEY")) {
|
|
19
|
+
userMessage = "Authentication failed. Please verify your Redash API key.";
|
|
20
|
+
}
|
|
21
|
+
else if (detail.includes("403") || detail.includes("Access denied")) {
|
|
22
|
+
userMessage = "Access denied. You do not have permission for this resource.";
|
|
23
|
+
}
|
|
24
|
+
else if (/\b404\b/.test(detail) || detail.includes("Redash API error (404)")) {
|
|
25
|
+
userMessage = "The requested resource was not found. Please check the ID and try again.";
|
|
26
|
+
}
|
|
27
|
+
else if (detail.includes("Redash API error")) {
|
|
28
|
+
userMessage = "The Redash API returned an error. Please try again later.";
|
|
29
|
+
}
|
|
30
|
+
else if (detail.includes("fetch failed") || detail.includes("ECONNREFUSED") || detail.includes("ENOTFOUND")) {
|
|
31
|
+
userMessage = "Unable to connect to the Redash server. Please check REDASH_URL and network connectivity.";
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
userMessage = "An unexpected error occurred. Please try again or check the server logs.";
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: userMessage }],
|
|
38
|
+
isError: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redash-mcp",
|
|
3
3
|
"mcpName": "io.github.seob717/redash-mcp",
|
|
4
|
-
"version": "3.0
|
|
4
|
+
"version": "3.1.0",
|
|
5
5
|
"description": "MCP server for Redash",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "seob717",
|
|
@@ -32,17 +32,20 @@
|
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc",
|
|
34
34
|
"dev": "tsx src/index.ts",
|
|
35
|
-
"start": "node dist/index.js"
|
|
35
|
+
"start": "node dist/index.js",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
|
-
"@anthropic-ai/sdk": "^0.
|
|
39
|
-
"@clack/prompts": "^1.
|
|
40
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
41
|
-
"zod": "^
|
|
40
|
+
"@anthropic-ai/sdk": "^0.95.2",
|
|
41
|
+
"@clack/prompts": "^1.4.0",
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
|
+
"zod": "^4.4.3"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
|
44
46
|
"@types/node": "^22.0.0",
|
|
45
47
|
"tsx": "^4.0.0",
|
|
46
|
-
"typescript": "^5.0.0"
|
|
48
|
+
"typescript": "^5.0.0",
|
|
49
|
+
"vitest": "^4.1.6"
|
|
47
50
|
}
|
|
48
51
|
}
|