redash-mcp 3.1.3 → 3.1.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/index.js +43010 -512
- package/manifest.json +27 -2
- package/package.json +5 -3
- package/dist/bird/complexity.js +0 -87
- package/dist/bird/config.js +0 -73
- package/dist/bird/evaluation.js +0 -172
- package/dist/bird/feedback.js +0 -153
- package/dist/bird/few-shot.js +0 -95
- package/dist/bird/keyword-map.js +0 -56
- package/dist/bird/llm-table-selector.js +0 -59
- package/dist/bird/schema-pruning.js +0 -107
- package/dist/bird/smart-query.js +0 -122
- package/dist/bird/tools.js +0 -319
- package/dist/bird/types.js +0 -1
- package/dist/query-cache.js +0 -87
- package/dist/redash-client.js +0 -124
- package/dist/setup.js +0 -137
- package/dist/sql-guard.js +0 -151
- package/dist/tool-error.js +0 -40
package/dist/query-cache.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { createHash } from "crypto";
|
|
2
|
-
const cache = new Map();
|
|
3
|
-
let totalSizeBytes = 0;
|
|
4
|
-
function getCacheTtlMs() {
|
|
5
|
-
const ttl = parseInt(process.env.REDASH_MCP_CACHE_TTL ?? "300", 10);
|
|
6
|
-
return (isNaN(ttl) ? 300 : ttl) * 1000;
|
|
7
|
-
}
|
|
8
|
-
function getMaxSizeBytes() {
|
|
9
|
-
const mb = parseInt(process.env.REDASH_MCP_CACHE_MAX_MB ?? "50", 10);
|
|
10
|
-
return (isNaN(mb) ? 50 : mb) * 1024 * 1024;
|
|
11
|
-
}
|
|
12
|
-
function normalizeSQL(sql) {
|
|
13
|
-
return sql
|
|
14
|
-
.replace(/--[^\n]*/g, " ")
|
|
15
|
-
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
16
|
-
.replace(/\s+/g, " ")
|
|
17
|
-
.trim()
|
|
18
|
-
.toLowerCase();
|
|
19
|
-
}
|
|
20
|
-
function makeCacheKey(dataSourceId, sql) {
|
|
21
|
-
return createHash("sha256")
|
|
22
|
-
.update(`${dataSourceId}:${normalizeSQL(sql)}`)
|
|
23
|
-
.digest("hex");
|
|
24
|
-
}
|
|
25
|
-
function roughSize(obj) {
|
|
26
|
-
return JSON.stringify(obj).length * 2;
|
|
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
|
-
}
|
|
39
|
-
export function getCached(dataSourceId, sql) {
|
|
40
|
-
const ttl = getCacheTtlMs();
|
|
41
|
-
if (ttl === 0)
|
|
42
|
-
return null;
|
|
43
|
-
const key = makeCacheKey(dataSourceId, sql);
|
|
44
|
-
const entry = cache.get(key);
|
|
45
|
-
if (!entry)
|
|
46
|
-
return null;
|
|
47
|
-
if (Date.now() - entry.ts > ttl) {
|
|
48
|
-
totalSizeBytes -= entry.size;
|
|
49
|
-
cache.delete(key);
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
// Touch entry to maintain LRU order via insertion-order Map semantics.
|
|
53
|
-
cache.delete(key);
|
|
54
|
-
cache.set(key, entry);
|
|
55
|
-
return entry.result;
|
|
56
|
-
}
|
|
57
|
-
export function setCached(dataSourceId, sql, result) {
|
|
58
|
-
const ttl = getCacheTtlMs();
|
|
59
|
-
if (ttl === 0)
|
|
60
|
-
return;
|
|
61
|
-
const maxSize = getMaxSizeBytes();
|
|
62
|
-
const size = roughSize(result);
|
|
63
|
-
if (size > maxSize * 0.2)
|
|
64
|
-
return;
|
|
65
|
-
purgeExpired(ttl);
|
|
66
|
-
while (totalSizeBytes + size > maxSize && cache.size > 0) {
|
|
67
|
-
const oldestKey = cache.keys().next().value;
|
|
68
|
-
if (!oldestKey)
|
|
69
|
-
break;
|
|
70
|
-
const old = cache.get(oldestKey);
|
|
71
|
-
totalSizeBytes -= old.size;
|
|
72
|
-
cache.delete(oldestKey);
|
|
73
|
-
}
|
|
74
|
-
const key = makeCacheKey(dataSourceId, sql);
|
|
75
|
-
const existing = cache.get(key);
|
|
76
|
-
if (existing)
|
|
77
|
-
totalSizeBytes -= existing.size;
|
|
78
|
-
cache.set(key, { result, ts: Date.now(), size });
|
|
79
|
-
totalSizeBytes += size;
|
|
80
|
-
}
|
|
81
|
-
export function getCacheStats() {
|
|
82
|
-
return {
|
|
83
|
-
entries: cache.size,
|
|
84
|
-
sizeMb: (totalSizeBytes / 1024 / 1024).toFixed(2),
|
|
85
|
-
ttlSecs: getCacheTtlMs() / 1000,
|
|
86
|
-
};
|
|
87
|
-
}
|
package/dist/redash-client.js
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
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);
|
|
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
|
-
})();
|
|
31
|
-
export { REDASH_URL, REDASH_API_KEY };
|
|
32
|
-
export async function redashFetch(path, options) {
|
|
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
|
-
}
|
|
56
|
-
if (!res.ok) {
|
|
57
|
-
let hint = "";
|
|
58
|
-
if (res.status === 401)
|
|
59
|
-
hint = " (Check your REDASH_API_KEY)";
|
|
60
|
-
else if (res.status === 403)
|
|
61
|
-
hint = " (Access denied for this resource)";
|
|
62
|
-
else if (res.status === 404)
|
|
63
|
-
hint = " (Resource not found. Check the ID)";
|
|
64
|
-
throw new Error(`Redash API error: ${res.status} ${res.statusText}${hint}`);
|
|
65
|
-
}
|
|
66
|
-
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
return res.json();
|
|
70
|
-
}
|
|
71
|
-
export async function pollQueryResult(jobId, timeoutSecs = 30) {
|
|
72
|
-
const deadline = Date.now() + timeoutSecs * 1000;
|
|
73
|
-
let delayMs = 250;
|
|
74
|
-
while (Date.now() < deadline) {
|
|
75
|
-
const job = await redashFetch(`/jobs/${jobId}`);
|
|
76
|
-
if (job.job.status === 3) {
|
|
77
|
-
return await redashFetch(`/query_results/${job.job.query_result_id}`);
|
|
78
|
-
}
|
|
79
|
-
if (job.job.status === 4) {
|
|
80
|
-
throw new Error(`Query failed: ${job.job.error}`);
|
|
81
|
-
}
|
|
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);
|
|
87
|
-
}
|
|
88
|
-
throw new Error(`Query timed out after ${timeoutSecs}s`);
|
|
89
|
-
}
|
|
90
|
-
export function formatAsMarkdownTable(columns, rows) {
|
|
91
|
-
const escape = (s) => s.replace(/\|/g, "\\|");
|
|
92
|
-
const header = `| ${columns.map(escape).join(" | ")} |`;
|
|
93
|
-
const separator = `| ${columns.map(() => "---").join(" | ")} |`;
|
|
94
|
-
const body = rows
|
|
95
|
-
.map((row) => `| ${columns.map((c) => escape(String(row[c] ?? ""))).join(" | ")} |`)
|
|
96
|
-
.join("\n");
|
|
97
|
-
return `${header}\n${separator}\n${body}`;
|
|
98
|
-
}
|
|
99
|
-
const schemaCache = new Map();
|
|
100
|
-
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
101
|
-
const SCHEMA_CACHE_MAX_ENTRIES = 32;
|
|
102
|
-
export async function fetchSchema(dataSourceId) {
|
|
103
|
-
const cached = schemaCache.get(dataSourceId);
|
|
104
|
-
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
|
|
105
|
-
schemaCache.delete(dataSourceId);
|
|
106
|
-
schemaCache.set(dataSourceId, cached);
|
|
107
|
-
return cached.schema;
|
|
108
|
-
}
|
|
109
|
-
if (cached)
|
|
110
|
-
schemaCache.delete(dataSourceId);
|
|
111
|
-
const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
|
|
112
|
-
const schema = (result.schema ?? []).map((table) => ({
|
|
113
|
-
...table,
|
|
114
|
-
columns: (table.columns ?? []).map((c) => typeof c === "string" ? { name: c, type: "unknown" } : c),
|
|
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
|
-
}
|
|
122
|
-
schemaCache.set(dataSourceId, { schema, ts: Date.now() });
|
|
123
|
-
return schema;
|
|
124
|
-
}
|
package/dist/setup.js
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
import * as path from "path";
|
|
4
|
-
import * as os from "os";
|
|
5
|
-
import { execSync } from "child_process";
|
|
6
|
-
import * as p from "@clack/prompts";
|
|
7
|
-
function findNpxPath() {
|
|
8
|
-
try {
|
|
9
|
-
const result = execSync("which npx", { encoding: "utf8" }).trim();
|
|
10
|
-
if (result)
|
|
11
|
-
return result;
|
|
12
|
-
}
|
|
13
|
-
catch { }
|
|
14
|
-
const candidates = [
|
|
15
|
-
"/usr/local/bin/npx",
|
|
16
|
-
"/opt/homebrew/bin/npx",
|
|
17
|
-
"/usr/bin/npx",
|
|
18
|
-
];
|
|
19
|
-
for (const c of candidates) {
|
|
20
|
-
if (fs.existsSync(c))
|
|
21
|
-
return c;
|
|
22
|
-
}
|
|
23
|
-
return "npx";
|
|
24
|
-
}
|
|
25
|
-
function getDesktopConfigPath() {
|
|
26
|
-
const platform = os.platform();
|
|
27
|
-
if (platform === "darwin") {
|
|
28
|
-
return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
29
|
-
}
|
|
30
|
-
else if (platform === "win32") {
|
|
31
|
-
return path.join(process.env.APPDATA ?? "", "Claude", "claude_desktop_config.json");
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function getClaudeCodeConfigPath() {
|
|
38
|
-
return path.join(os.homedir(), ".claude", "settings.json");
|
|
39
|
-
}
|
|
40
|
-
export async function main() {
|
|
41
|
-
p.intro("redash-mcp setup wizard");
|
|
42
|
-
const targets = await p.multiselect({
|
|
43
|
-
message: "Select installation targets (space to select, enter to confirm)",
|
|
44
|
-
options: [
|
|
45
|
-
{ value: "desktop", label: "Claude Desktop" },
|
|
46
|
-
{ value: "cli", label: "Claude Code (CLI)" },
|
|
47
|
-
],
|
|
48
|
-
required: true,
|
|
49
|
-
});
|
|
50
|
-
if (p.isCancel(targets)) {
|
|
51
|
-
p.cancel("Setup cancelled.");
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
const redashUrl = await p.text({
|
|
55
|
-
message: "Enter your Redash URL",
|
|
56
|
-
placeholder: "https://redash.example.com",
|
|
57
|
-
validate(value) {
|
|
58
|
-
if (!value)
|
|
59
|
-
return "URL is required.";
|
|
60
|
-
if (!value.startsWith("http://") && !value.startsWith("https://"))
|
|
61
|
-
return "Must start with http:// or https://";
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
if (p.isCancel(redashUrl)) {
|
|
65
|
-
p.cancel("Setup cancelled.");
|
|
66
|
-
process.exit(0);
|
|
67
|
-
}
|
|
68
|
-
const apiKey = await p.text({
|
|
69
|
-
message: "Enter your Redash API key",
|
|
70
|
-
validate(value) {
|
|
71
|
-
if (!value)
|
|
72
|
-
return "API key is required.";
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
if (p.isCancel(apiKey)) {
|
|
76
|
-
p.cancel("Setup cancelled.");
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
const url = redashUrl.replace(/\/$/, "");
|
|
80
|
-
const npxPath = findNpxPath();
|
|
81
|
-
const mcpEntry = {
|
|
82
|
-
command: npxPath,
|
|
83
|
-
args: ["-y", "redash-mcp"],
|
|
84
|
-
env: {
|
|
85
|
-
REDASH_URL: url,
|
|
86
|
-
REDASH_API_KEY: apiKey,
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
const s = p.spinner();
|
|
90
|
-
if (targets.includes("desktop")) {
|
|
91
|
-
s.start("Configuring Claude Desktop...");
|
|
92
|
-
setupDesktop(mcpEntry);
|
|
93
|
-
s.stop("Claude Desktop configured");
|
|
94
|
-
}
|
|
95
|
-
if (targets.includes("cli")) {
|
|
96
|
-
s.start("Configuring Claude Code (CLI)...");
|
|
97
|
-
setupClaudeCode(mcpEntry);
|
|
98
|
-
s.stop("Claude Code (CLI) configured");
|
|
99
|
-
}
|
|
100
|
-
p.outro("Setup complete. Restart to start using redash-mcp.");
|
|
101
|
-
}
|
|
102
|
-
function setupDesktop(mcpEntry) {
|
|
103
|
-
const configPath = getDesktopConfigPath();
|
|
104
|
-
let config = { mcpServers: {} };
|
|
105
|
-
if (fs.existsSync(configPath)) {
|
|
106
|
-
try {
|
|
107
|
-
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
108
|
-
config.mcpServers ??= {};
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
throw new Error(`Failed to read claude_desktop_config.json: ${configPath}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
116
|
-
}
|
|
117
|
-
config.mcpServers["redash-mcp"] = mcpEntry;
|
|
118
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
119
|
-
}
|
|
120
|
-
function setupClaudeCode(mcpEntry) {
|
|
121
|
-
const configPath = getClaudeCodeConfigPath();
|
|
122
|
-
let config = {};
|
|
123
|
-
if (fs.existsSync(configPath)) {
|
|
124
|
-
try {
|
|
125
|
-
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
throw new Error(`Failed to read Claude Code settings.json: ${configPath}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
133
|
-
}
|
|
134
|
-
config.mcpServers ??= {};
|
|
135
|
-
config.mcpServers["redash-mcp"] = mcpEntry;
|
|
136
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
137
|
-
}
|
package/dist/sql-guard.js
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
function getConfig() {
|
|
2
|
-
const raw = process.env.REDASH_SAFETY_MODE ?? "warn";
|
|
3
|
-
const mode = ["off", "warn", "strict"].includes(raw) ? raw : "warn";
|
|
4
|
-
return {
|
|
5
|
-
mode,
|
|
6
|
-
disablePii: process.env.REDASH_SAFETY_DISABLE_PII === "true",
|
|
7
|
-
disableCost: process.env.REDASH_SAFETY_DISABLE_COST === "true",
|
|
8
|
-
autoLimit: parseInt(process.env.REDASH_AUTO_LIMIT ?? "0", 10) || 0,
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
function normalizeForAnalysis(sql) {
|
|
12
|
-
return sql
|
|
13
|
-
.replace(/--[^\n]*/g, " ")
|
|
14
|
-
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
15
|
-
.replace(/\s+/g, " ")
|
|
16
|
-
.trim()
|
|
17
|
-
.toUpperCase();
|
|
18
|
-
}
|
|
19
|
-
function hasWhere(sql) {
|
|
20
|
-
return /\bWHERE\b/.test(sql);
|
|
21
|
-
}
|
|
22
|
-
function hasLimit(sql) {
|
|
23
|
-
return /\bLIMIT\b/.test(sql);
|
|
24
|
-
}
|
|
25
|
-
function isSelect(sql) {
|
|
26
|
-
return /^\s*(SELECT|WITH)\b/i.test(sql);
|
|
27
|
-
}
|
|
28
|
-
function injectLimit(sql, limit) {
|
|
29
|
-
if (/\bLIMIT\b/i.test(sql))
|
|
30
|
-
return sql;
|
|
31
|
-
if (!isSelect(sql))
|
|
32
|
-
return sql;
|
|
33
|
-
return `${sql.trimEnd()} LIMIT ${limit}`;
|
|
34
|
-
}
|
|
35
|
-
export function analyzeQuery(sql) {
|
|
36
|
-
const config = getConfig();
|
|
37
|
-
if (config.mode === "off") {
|
|
38
|
-
return { blocked: false, warnings: [], message: "" };
|
|
39
|
-
}
|
|
40
|
-
const upper = normalizeForAnalysis(sql);
|
|
41
|
-
const warnings = [];
|
|
42
|
-
let modifiedQuery;
|
|
43
|
-
if (/\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW|INDEX|FUNCTION)\b/.test(upper)) {
|
|
44
|
-
return {
|
|
45
|
-
blocked: true,
|
|
46
|
-
warnings: [],
|
|
47
|
-
message: "🚫 Query blocked.\n\nReason: DROP statements permanently delete data/schema.\nRule: DESTRUCTIVE / DROP\n\nSet REDASH_SAFETY_MODE=off to disable this check.",
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
if (/\bTRUNCATE\b/.test(upper)) {
|
|
51
|
-
return {
|
|
52
|
-
blocked: true,
|
|
53
|
-
warnings: [],
|
|
54
|
-
message: "🚫 Query blocked.\n\nReason: TRUNCATE deletes all data from the table.\nRule: DESTRUCTIVE / TRUNCATE",
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
if (/\bALTER\s+TABLE\b/.test(upper)) {
|
|
58
|
-
return {
|
|
59
|
-
blocked: true,
|
|
60
|
-
warnings: [],
|
|
61
|
-
message: "🚫 Query blocked.\n\nReason: ALTER TABLE modifies schema and requires prior coordination.\nRule: DESTRUCTIVE / ALTER_TABLE",
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
if (/\b(GRANT|REVOKE)\b/.test(upper)) {
|
|
65
|
-
return {
|
|
66
|
-
blocked: true,
|
|
67
|
-
warnings: [],
|
|
68
|
-
message: "🚫 Query blocked.\n\nReason: GRANT/REVOKE permission changes are not allowed.\nRule: DESTRUCTIVE / PRIVILEGE_CHANGE",
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
if (/\bDELETE\s+FROM\b/.test(upper) && !hasWhere(upper)) {
|
|
72
|
-
return {
|
|
73
|
-
blocked: true,
|
|
74
|
-
warnings: [],
|
|
75
|
-
message: "🚫 Query blocked.\n\nReason: DELETE without WHERE clause will delete all rows.\nRule: DESTRUCTIVE / DELETE_WITHOUT_WHERE\n\nSafe example:\n DELETE FROM orders WHERE created_at < '2024-01-01'",
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
if (/\bUPDATE\b/.test(upper) && /\bSET\b/.test(upper) && !hasWhere(upper)) {
|
|
79
|
-
return {
|
|
80
|
-
blocked: true,
|
|
81
|
-
warnings: [],
|
|
82
|
-
message: "🚫 Query blocked.\n\nReason: UPDATE without WHERE clause will modify all rows.\nRule: DESTRUCTIVE / UPDATE_WITHOUT_WHERE\n\nSafe example:\n UPDATE orders SET status = 'cancelled' WHERE created_at < '2024-01-01'",
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
if (/\bDELETE\s+FROM\b/.test(upper)) {
|
|
86
|
-
warnings.push("[DESTRUCTIVE] DELETE query detected. Please verify the WHERE clause.");
|
|
87
|
-
}
|
|
88
|
-
if (/\bUPDATE\b/.test(upper) && /\bSET\b/.test(upper)) {
|
|
89
|
-
warnings.push("[DESTRUCTIVE] UPDATE query detected. Please verify the WHERE clause.");
|
|
90
|
-
}
|
|
91
|
-
if (!config.disableCost && isSelect(sql)) {
|
|
92
|
-
const hasSelectStar = /SELECT\s+\*/.test(upper) || /SELECT\s+[\w.]+\.\*/.test(upper);
|
|
93
|
-
const noWhere = !hasWhere(upper);
|
|
94
|
-
const noLimit = !hasLimit(upper);
|
|
95
|
-
if (hasSelectStar) {
|
|
96
|
-
warnings.push("[COST] SELECT * detected. Specify only needed columns to reduce scan costs.");
|
|
97
|
-
}
|
|
98
|
-
if (noWhere) {
|
|
99
|
-
warnings.push("[COST] No WHERE clause. Consider adding date or condition filters.");
|
|
100
|
-
}
|
|
101
|
-
if (noLimit) {
|
|
102
|
-
if (config.autoLimit > 0) {
|
|
103
|
-
modifiedQuery = injectLimit(sql, config.autoLimit);
|
|
104
|
-
warnings.push(`[COST] No LIMIT clause — auto-appended LIMIT ${config.autoLimit}. Specify an explicit LIMIT if you need all rows.`);
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
warnings.push("[COST] No LIMIT clause. Full table scans on large tables may incur significant costs.");
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (config.mode === "strict") {
|
|
111
|
-
const costWarnings = warnings.filter((w) => w.startsWith("[COST]"));
|
|
112
|
-
if (costWarnings.length > 0) {
|
|
113
|
-
return {
|
|
114
|
-
blocked: true,
|
|
115
|
-
warnings: [],
|
|
116
|
-
message: `🚫 Query blocked (strict mode).\n\n${costWarnings.join("\n")}\n\nSet REDASH_SAFETY_MODE=warn to allow with warnings.`,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (!config.disablePii) {
|
|
122
|
-
const piiPatterns = [
|
|
123
|
-
"EMAIL",
|
|
124
|
-
"PHONE",
|
|
125
|
-
"PASSWORD",
|
|
126
|
-
"PASSWD",
|
|
127
|
-
"SSN",
|
|
128
|
-
"SOCIAL_SECURITY",
|
|
129
|
-
"CREDIT_CARD",
|
|
130
|
-
"CARD_NUMBER",
|
|
131
|
-
];
|
|
132
|
-
const matched = piiPatterns.filter((k) => upper.includes(k));
|
|
133
|
-
if (matched.length > 0) {
|
|
134
|
-
warnings.push(`[PII] Sensitive data columns detected: ${matched.join(", ")}. Please verify your data privacy compliance.`);
|
|
135
|
-
}
|
|
136
|
-
if (config.mode === "strict") {
|
|
137
|
-
const piiWarnings = warnings.filter((w) => w.startsWith("[PII]"));
|
|
138
|
-
if (piiWarnings.length > 0) {
|
|
139
|
-
return {
|
|
140
|
-
blocked: true,
|
|
141
|
-
warnings: [],
|
|
142
|
-
message: `🚫 Query blocked (strict mode).\n\n${piiWarnings.join("\n")}\n\nSet REDASH_SAFETY_MODE=warn to allow with warnings.`,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
const message = warnings.length > 0
|
|
148
|
-
? `⚠️ Safety warnings (query will still execute)\n\n${warnings.join("\n")}\n\n---`
|
|
149
|
-
: "";
|
|
150
|
-
return { blocked: false, warnings, message, modifiedQuery };
|
|
151
|
-
}
|
package/dist/tool-error.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
}
|