redash-mcp 2.2.1 → 3.0.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/LICENSE +21 -0
- package/README.ko.md +223 -0
- package/README.md +167 -47
- package/dist/bird/complexity.js +87 -0
- package/dist/bird/config.js +73 -0
- package/dist/bird/evaluation.js +184 -0
- package/dist/bird/feedback.js +154 -0
- package/dist/bird/few-shot.js +95 -0
- package/dist/bird/keyword-map.js +56 -0
- package/dist/bird/llm-table-selector.js +59 -0
- package/dist/bird/schema-pruning.js +107 -0
- package/dist/bird/smart-query.js +117 -0
- package/dist/bird/tools.js +288 -0
- package/dist/bird/types.js +1 -0
- package/dist/index.js +101 -163
- package/dist/query-cache.js +0 -2
- package/dist/redash-client.js +64 -0
- package/dist/setup.js +17 -80
- package/dist/sql-guard.js +16 -24
- package/manifest.json +46 -0
- package/package.json +18 -4
- package/.gitattributes +0 -1
- package/docs/sql-safety-guard.md +0 -197
package/dist/setup.js
CHANGED
|
@@ -38,9 +38,9 @@ function getClaudeCodeConfigPath() {
|
|
|
38
38
|
return path.join(os.homedir(), ".claude", "settings.json");
|
|
39
39
|
}
|
|
40
40
|
export async function main() {
|
|
41
|
-
p.intro("redash-mcp
|
|
41
|
+
p.intro("redash-mcp setup wizard");
|
|
42
42
|
const targets = await p.multiselect({
|
|
43
|
-
message: "
|
|
43
|
+
message: "Select installation targets (space to select, enter to confirm)",
|
|
44
44
|
options: [
|
|
45
45
|
{ value: "desktop", label: "Claude Desktop" },
|
|
46
46
|
{ value: "cli", label: "Claude Code (CLI)" },
|
|
@@ -48,91 +48,32 @@ export async function main() {
|
|
|
48
48
|
required: true,
|
|
49
49
|
});
|
|
50
50
|
if (p.isCancel(targets)) {
|
|
51
|
-
p.cancel("
|
|
51
|
+
p.cancel("Setup cancelled.");
|
|
52
52
|
process.exit(0);
|
|
53
53
|
}
|
|
54
54
|
const redashUrl = await p.text({
|
|
55
|
-
message: "Redash URL
|
|
55
|
+
message: "Enter your Redash URL",
|
|
56
56
|
placeholder: "https://redash.example.com",
|
|
57
57
|
validate(value) {
|
|
58
58
|
if (!value)
|
|
59
|
-
return "URL
|
|
59
|
+
return "URL is required.";
|
|
60
60
|
if (!value.startsWith("http://") && !value.startsWith("https://"))
|
|
61
|
-
return "http://
|
|
61
|
+
return "Must start with http:// or https://";
|
|
62
62
|
},
|
|
63
63
|
});
|
|
64
64
|
if (p.isCancel(redashUrl)) {
|
|
65
|
-
p.cancel("
|
|
65
|
+
p.cancel("Setup cancelled.");
|
|
66
66
|
process.exit(0);
|
|
67
67
|
}
|
|
68
68
|
const apiKey = await p.text({
|
|
69
|
-
message: "Redash API
|
|
69
|
+
message: "Enter your Redash API key",
|
|
70
70
|
validate(value) {
|
|
71
71
|
if (!value)
|
|
72
|
-
return "API
|
|
72
|
+
return "API key is required.";
|
|
73
73
|
},
|
|
74
74
|
});
|
|
75
75
|
if (p.isCancel(apiKey)) {
|
|
76
|
-
p.cancel("
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
// ── 안전 모드 설정 ────────────────────────────────────────────────────────
|
|
80
|
-
const safetyMode = await p.select({
|
|
81
|
-
message: "SQL 안전 모드를 선택하세요",
|
|
82
|
-
options: [
|
|
83
|
-
{ value: "warn", label: "warn — 위험 쿼리 경고 후 실행 (권장)" },
|
|
84
|
-
{ value: "strict", label: "strict — 위험 쿼리 차단" },
|
|
85
|
-
{ value: "off", label: "off — 제한 없음 (관리자 전용)" },
|
|
86
|
-
],
|
|
87
|
-
initialValue: "warn",
|
|
88
|
-
});
|
|
89
|
-
if (p.isCancel(safetyMode)) {
|
|
90
|
-
p.cancel("설치가 취소되었습니다.");
|
|
91
|
-
process.exit(0);
|
|
92
|
-
}
|
|
93
|
-
const autoLimitRaw = await p.text({
|
|
94
|
-
message: "자동 LIMIT 값을 입력하세요 (0 = 비활성화)",
|
|
95
|
-
placeholder: "1000",
|
|
96
|
-
initialValue: "1000",
|
|
97
|
-
validate(value) {
|
|
98
|
-
if (!value)
|
|
99
|
-
return undefined;
|
|
100
|
-
if (isNaN(parseInt(value, 10)))
|
|
101
|
-
return "숫자를 입력해주세요.";
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
if (p.isCancel(autoLimitRaw)) {
|
|
105
|
-
p.cancel("설치가 취소되었습니다.");
|
|
106
|
-
process.exit(0);
|
|
107
|
-
}
|
|
108
|
-
const defaultMaxAgeRaw = await p.text({
|
|
109
|
-
message: "Redash 캐시 유지 시간을 입력하세요 (초, 0 = 항상 새로 실행)",
|
|
110
|
-
placeholder: "600",
|
|
111
|
-
initialValue: "600",
|
|
112
|
-
validate(value) {
|
|
113
|
-
if (!value)
|
|
114
|
-
return undefined;
|
|
115
|
-
if (isNaN(parseInt(value, 10)))
|
|
116
|
-
return "숫자를 입력해주세요.";
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
if (p.isCancel(defaultMaxAgeRaw)) {
|
|
120
|
-
p.cancel("설치가 취소되었습니다.");
|
|
121
|
-
process.exit(0);
|
|
122
|
-
}
|
|
123
|
-
const mcpCacheTtlRaw = await p.text({
|
|
124
|
-
message: "MCP 레이어 캐시 TTL을 입력하세요 (초, 0 = 비활성화)",
|
|
125
|
-
placeholder: "300",
|
|
126
|
-
initialValue: "300",
|
|
127
|
-
validate(value) {
|
|
128
|
-
if (!value)
|
|
129
|
-
return undefined;
|
|
130
|
-
if (isNaN(parseInt(value, 10)))
|
|
131
|
-
return "숫자를 입력해주세요.";
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
if (p.isCancel(mcpCacheTtlRaw)) {
|
|
135
|
-
p.cancel("설치가 취소되었습니다.");
|
|
76
|
+
p.cancel("Setup cancelled.");
|
|
136
77
|
process.exit(0);
|
|
137
78
|
}
|
|
138
79
|
const url = redashUrl.replace(/\/$/, "");
|
|
@@ -143,24 +84,20 @@ export async function main() {
|
|
|
143
84
|
env: {
|
|
144
85
|
REDASH_URL: url,
|
|
145
86
|
REDASH_API_KEY: apiKey,
|
|
146
|
-
REDASH_SAFETY_MODE: String(safetyMode),
|
|
147
|
-
REDASH_AUTO_LIMIT: String(autoLimitRaw || "1000"),
|
|
148
|
-
REDASH_DEFAULT_MAX_AGE: String(defaultMaxAgeRaw || "600"),
|
|
149
|
-
REDASH_MCP_CACHE_TTL: String(mcpCacheTtlRaw || "300"),
|
|
150
87
|
},
|
|
151
88
|
};
|
|
152
89
|
const s = p.spinner();
|
|
153
90
|
if (targets.includes("desktop")) {
|
|
154
|
-
s.start("Claude Desktop
|
|
91
|
+
s.start("Configuring Claude Desktop...");
|
|
155
92
|
setupDesktop(mcpEntry);
|
|
156
|
-
s.stop("Claude Desktop
|
|
93
|
+
s.stop("Claude Desktop configured");
|
|
157
94
|
}
|
|
158
95
|
if (targets.includes("cli")) {
|
|
159
|
-
s.start("Claude Code (CLI)
|
|
96
|
+
s.start("Configuring Claude Code (CLI)...");
|
|
160
97
|
setupClaudeCode(mcpEntry);
|
|
161
|
-
s.stop("Claude Code (CLI)
|
|
98
|
+
s.stop("Claude Code (CLI) configured");
|
|
162
99
|
}
|
|
163
|
-
p.outro("
|
|
100
|
+
p.outro("Setup complete. Restart to start using redash-mcp.");
|
|
164
101
|
}
|
|
165
102
|
function setupDesktop(mcpEntry) {
|
|
166
103
|
const configPath = getDesktopConfigPath();
|
|
@@ -171,7 +108,7 @@ function setupDesktop(mcpEntry) {
|
|
|
171
108
|
config.mcpServers ??= {};
|
|
172
109
|
}
|
|
173
110
|
catch {
|
|
174
|
-
throw new Error(`claude_desktop_config.json
|
|
111
|
+
throw new Error(`Failed to read claude_desktop_config.json: ${configPath}`);
|
|
175
112
|
}
|
|
176
113
|
}
|
|
177
114
|
else {
|
|
@@ -188,7 +125,7 @@ function setupClaudeCode(mcpEntry) {
|
|
|
188
125
|
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
189
126
|
}
|
|
190
127
|
catch {
|
|
191
|
-
throw new Error(`Claude Code settings.json
|
|
128
|
+
throw new Error(`Failed to read Claude Code settings.json: ${configPath}`);
|
|
192
129
|
}
|
|
193
130
|
}
|
|
194
131
|
else {
|
package/dist/sql-guard.js
CHANGED
|
@@ -40,74 +40,71 @@ export function analyzeQuery(sql) {
|
|
|
40
40
|
const upper = normalizeForAnalysis(sql);
|
|
41
41
|
const warnings = [];
|
|
42
42
|
let modifiedQuery;
|
|
43
|
-
// ── Destructive (차단: warn/strict 모두) ────────────────────────────────────
|
|
44
43
|
if (/\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW|INDEX|FUNCTION)\b/.test(upper)) {
|
|
45
44
|
return {
|
|
46
45
|
blocked: true,
|
|
47
46
|
warnings: [],
|
|
48
|
-
message: "🚫
|
|
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.",
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
50
|
if (/\bTRUNCATE\b/.test(upper)) {
|
|
52
51
|
return {
|
|
53
52
|
blocked: true,
|
|
54
53
|
warnings: [],
|
|
55
|
-
message: "🚫
|
|
54
|
+
message: "🚫 Query blocked.\n\nReason: TRUNCATE deletes all data from the table.\nRule: DESTRUCTIVE / TRUNCATE",
|
|
56
55
|
};
|
|
57
56
|
}
|
|
58
57
|
if (/\bALTER\s+TABLE\b/.test(upper)) {
|
|
59
58
|
return {
|
|
60
59
|
blocked: true,
|
|
61
60
|
warnings: [],
|
|
62
|
-
message: "🚫
|
|
61
|
+
message: "🚫 Query blocked.\n\nReason: ALTER TABLE modifies schema and requires prior coordination.\nRule: DESTRUCTIVE / ALTER_TABLE",
|
|
63
62
|
};
|
|
64
63
|
}
|
|
65
64
|
if (/\b(GRANT|REVOKE)\b/.test(upper)) {
|
|
66
65
|
return {
|
|
67
66
|
blocked: true,
|
|
68
67
|
warnings: [],
|
|
69
|
-
message: "🚫
|
|
68
|
+
message: "🚫 Query blocked.\n\nReason: GRANT/REVOKE permission changes are not allowed.\nRule: DESTRUCTIVE / PRIVILEGE_CHANGE",
|
|
70
69
|
};
|
|
71
70
|
}
|
|
72
71
|
if (/\bDELETE\s+FROM\b/.test(upper) && !hasWhere(upper)) {
|
|
73
72
|
return {
|
|
74
73
|
blocked: true,
|
|
75
74
|
warnings: [],
|
|
76
|
-
message: "🚫
|
|
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'",
|
|
77
76
|
};
|
|
78
77
|
}
|
|
79
78
|
if (/\bUPDATE\b/.test(upper) && /\bSET\b/.test(upper) && !hasWhere(upper)) {
|
|
80
79
|
return {
|
|
81
80
|
blocked: true,
|
|
82
81
|
warnings: [],
|
|
83
|
-
message: "🚫
|
|
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'",
|
|
84
83
|
};
|
|
85
84
|
}
|
|
86
|
-
// DELETE/UPDATE with WHERE — 경고만
|
|
87
85
|
if (/\bDELETE\s+FROM\b/.test(upper)) {
|
|
88
|
-
warnings.push("[DESTRUCTIVE] DELETE
|
|
86
|
+
warnings.push("[DESTRUCTIVE] DELETE query detected. Please verify the WHERE clause.");
|
|
89
87
|
}
|
|
90
88
|
if (/\bUPDATE\b/.test(upper) && /\bSET\b/.test(upper)) {
|
|
91
|
-
warnings.push("[DESTRUCTIVE] UPDATE
|
|
89
|
+
warnings.push("[DESTRUCTIVE] UPDATE query detected. Please verify the WHERE clause.");
|
|
92
90
|
}
|
|
93
|
-
// ── Cost (warn: 경고 후 실행, strict: 차단) ────────────────────────────────
|
|
94
91
|
if (!config.disableCost && isSelect(sql)) {
|
|
95
92
|
const hasSelectStar = /SELECT\s+\*/.test(upper) || /SELECT\s+[\w.]+\.\*/.test(upper);
|
|
96
93
|
const noWhere = !hasWhere(upper);
|
|
97
94
|
const noLimit = !hasLimit(upper);
|
|
98
95
|
if (hasSelectStar) {
|
|
99
|
-
warnings.push("[COST] SELECT
|
|
96
|
+
warnings.push("[COST] SELECT * detected. Specify only needed columns to reduce scan costs.");
|
|
100
97
|
}
|
|
101
98
|
if (noWhere) {
|
|
102
|
-
warnings.push("[COST] WHERE
|
|
99
|
+
warnings.push("[COST] No WHERE clause. Consider adding date or condition filters.");
|
|
103
100
|
}
|
|
104
101
|
if (noLimit) {
|
|
105
102
|
if (config.autoLimit > 0) {
|
|
106
103
|
modifiedQuery = injectLimit(sql, config.autoLimit);
|
|
107
|
-
warnings.push(`[COST] LIMIT
|
|
104
|
+
warnings.push(`[COST] No LIMIT clause — auto-appended LIMIT ${config.autoLimit}. Specify an explicit LIMIT if you need all rows.`);
|
|
108
105
|
}
|
|
109
106
|
else {
|
|
110
|
-
warnings.push("[COST] LIMIT
|
|
107
|
+
warnings.push("[COST] No LIMIT clause. Full table scans on large tables may incur significant costs.");
|
|
111
108
|
}
|
|
112
109
|
}
|
|
113
110
|
if (config.mode === "strict") {
|
|
@@ -116,12 +113,11 @@ export function analyzeQuery(sql) {
|
|
|
116
113
|
return {
|
|
117
114
|
blocked: true,
|
|
118
115
|
warnings: [],
|
|
119
|
-
message: `🚫
|
|
116
|
+
message: `🚫 Query blocked (strict mode).\n\n${costWarnings.join("\n")}\n\nSet REDASH_SAFETY_MODE=warn to allow with warnings.`,
|
|
120
117
|
};
|
|
121
118
|
}
|
|
122
119
|
}
|
|
123
120
|
}
|
|
124
|
-
// ── PII (warn: 경고 후 실행, strict: 차단) ────────────────────────────────
|
|
125
121
|
if (!config.disablePii) {
|
|
126
122
|
const piiPatterns = [
|
|
127
123
|
"EMAIL",
|
|
@@ -132,14 +128,10 @@ export function analyzeQuery(sql) {
|
|
|
132
128
|
"SOCIAL_SECURITY",
|
|
133
129
|
"CREDIT_CARD",
|
|
134
130
|
"CARD_NUMBER",
|
|
135
|
-
"주민",
|
|
136
|
-
"휴대폰",
|
|
137
|
-
"핸드폰",
|
|
138
|
-
"생년월일",
|
|
139
131
|
];
|
|
140
132
|
const matched = piiPatterns.filter((k) => upper.includes(k));
|
|
141
133
|
if (matched.length > 0) {
|
|
142
|
-
warnings.push(`[PII]
|
|
134
|
+
warnings.push(`[PII] Sensitive data columns detected: ${matched.join(", ")}. Please verify your data privacy compliance.`);
|
|
143
135
|
}
|
|
144
136
|
if (config.mode === "strict") {
|
|
145
137
|
const piiWarnings = warnings.filter((w) => w.startsWith("[PII]"));
|
|
@@ -147,13 +139,13 @@ export function analyzeQuery(sql) {
|
|
|
147
139
|
return {
|
|
148
140
|
blocked: true,
|
|
149
141
|
warnings: [],
|
|
150
|
-
message: `🚫
|
|
142
|
+
message: `🚫 Query blocked (strict mode).\n\n${piiWarnings.join("\n")}\n\nSet REDASH_SAFETY_MODE=warn to allow with warnings.`,
|
|
151
143
|
};
|
|
152
144
|
}
|
|
153
145
|
}
|
|
154
146
|
}
|
|
155
147
|
const message = warnings.length > 0
|
|
156
|
-
? `⚠️
|
|
148
|
+
? `⚠️ Safety warnings (query will still execute)\n\n${warnings.join("\n")}\n\n---`
|
|
157
149
|
: "";
|
|
158
150
|
return { blocked: false, warnings, message, modifiedQuery };
|
|
159
151
|
}
|
package/manifest.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": "0.3",
|
|
3
|
+
"name": "redash-mcp",
|
|
4
|
+
"version": "3.0.0",
|
|
5
|
+
"description": "MCP server for Redash -- query data, manage dashboards, and run SQL with natural language. 24 tools including BIRD SQL methodology.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "seob717",
|
|
8
|
+
"email": "dev.seob717@gmail.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/seob717/redash-mcp"
|
|
14
|
+
},
|
|
15
|
+
"privacy_policies": [
|
|
16
|
+
"https://github.com/seob717/redash-mcp#privacy-policy"
|
|
17
|
+
],
|
|
18
|
+
"support": "https://github.com/seob717/redash-mcp/issues",
|
|
19
|
+
"server": {
|
|
20
|
+
"type": "node",
|
|
21
|
+
"entry_point": "dist/index.js",
|
|
22
|
+
"mcp_config": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "redash-mcp"],
|
|
25
|
+
"env": {
|
|
26
|
+
"REDASH_URL": "${user_config.redash_url}",
|
|
27
|
+
"REDASH_API_KEY": "${user_config.redash_api_key}"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"user_config": {
|
|
32
|
+
"redash_url": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"title": "Redash URL",
|
|
35
|
+
"description": "Your Redash instance URL (e.g. https://redash.example.com)",
|
|
36
|
+
"required": true
|
|
37
|
+
},
|
|
38
|
+
"redash_api_key": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"title": "Redash API Key",
|
|
41
|
+
"description": "Your Redash user API key (found in Settings > Account)",
|
|
42
|
+
"sensitive": true,
|
|
43
|
+
"required": true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redash-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "MCP server for Redash",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "seob717",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"redash",
|
|
10
|
+
"sql",
|
|
11
|
+
"bi",
|
|
12
|
+
"dashboard",
|
|
13
|
+
"model-context-protocol"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
5
18
|
"repository": {
|
|
6
19
|
"type": "git",
|
|
7
|
-
"url": "https://github.com/
|
|
20
|
+
"url": "https://github.com/jiro-developers/redash-mcp.git"
|
|
8
21
|
},
|
|
9
|
-
"homepage": "https://github.com/
|
|
22
|
+
"homepage": "https://github.com/jiro-developers/redash-mcp#readme",
|
|
10
23
|
"bugs": {
|
|
11
|
-
"url": "https://github.com/
|
|
24
|
+
"url": "https://github.com/jiro-developers/redash-mcp/issues"
|
|
12
25
|
},
|
|
13
26
|
"type": "module",
|
|
14
27
|
"main": "dist/index.js",
|
|
@@ -21,6 +34,7 @@
|
|
|
21
34
|
"start": "node dist/index.js"
|
|
22
35
|
},
|
|
23
36
|
"dependencies": {
|
|
37
|
+
"@anthropic-ai/sdk": "^0.80.0",
|
|
24
38
|
"@clack/prompts": "^1.1.0",
|
|
25
39
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
26
40
|
"zod": "^3.0.0"
|
package/.gitattributes
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
package-lock.json linguist-generated=true
|
package/docs/sql-safety-guard.md
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
# SQL Safety Guard — 설계 문서
|
|
2
|
-
|
|
3
|
-
## 개요
|
|
4
|
-
|
|
5
|
-
`redash-mcp`에 SQL 안전 가드 레이어를 추가하여 MCP를 통해 실행되는 쿼리로 인한
|
|
6
|
-
비용 발생, 데이터 손실, 개인정보 노출을 사전에 방지한다.
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## 배경 및 목적
|
|
11
|
-
|
|
12
|
-
- Redash를 MCP로 연결하면 AI가 자동으로 쿼리를 생성·실행하는 환경이 된다
|
|
13
|
-
- 사용자가 SQL을 직접 검토하지 않는 경우가 많아 위험 쿼리가 그대로 실행될 수 있음
|
|
14
|
-
- BigQuery는 스캔한 데이터 용량 기준으로 과금되어 예상치 못한 비용 발생 가능
|
|
15
|
-
- AI가 생성한 쿼리에 DROP, DELETE 등이 포함될 경우 데이터 손실 위험
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## 아키텍처
|
|
20
|
-
|
|
21
|
-
### 위치
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
AI (Claude)
|
|
25
|
-
↓ tool call
|
|
26
|
-
run_query (MCP 도구)
|
|
27
|
-
↓
|
|
28
|
-
[SQL Safety Guard] ← 여기에 삽입
|
|
29
|
-
↓ 통과 시
|
|
30
|
-
Redash API
|
|
31
|
-
↓
|
|
32
|
-
BigQuery
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
### 패턴
|
|
36
|
-
|
|
37
|
-
미들웨어/인터셉터 패턴. `run_query` 핸들러 내부에서 Redash API 호출 전에 실행.
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
// src/sql-guard.ts
|
|
41
|
-
export function analyzeQuery(sql: string, mode: SafetyMode): SafetyResult
|
|
42
|
-
|
|
43
|
-
// src/index.ts (run_query 핸들러)
|
|
44
|
-
const result = analyzeQuery(query, safetyMode);
|
|
45
|
-
if (result.blocked) {
|
|
46
|
-
return { content: [{ type: "text", text: result.message }] };
|
|
47
|
-
}
|
|
48
|
-
// ... Redash API 호출
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
---
|
|
52
|
-
|
|
53
|
-
## 감지 규칙
|
|
54
|
-
|
|
55
|
-
### Category 1: 데이터 파괴 (Destructive) — 기본: 차단
|
|
56
|
-
|
|
57
|
-
| 규칙 | 패턴 예시 | 기본 동작 |
|
|
58
|
-
|---|---|---|
|
|
59
|
-
| DDL 변경 | `DROP TABLE`, `TRUNCATE`, `ALTER TABLE` | 차단 |
|
|
60
|
-
| 조건 없는 삭제 | `DELETE FROM table` (WHERE 없음) | 차단 |
|
|
61
|
-
| 조건 없는 수정 | `UPDATE table SET ...` (WHERE 없음) | 차단 |
|
|
62
|
-
| 권한 변경 | `GRANT`, `REVOKE` | 차단 |
|
|
63
|
-
|
|
64
|
-
### Category 2: 비용 위험 (Cost) — 기본: 경고
|
|
65
|
-
|
|
66
|
-
| 규칙 | 패턴 예시 | 기본 동작 |
|
|
67
|
-
|---|---|---|
|
|
68
|
-
| WHERE 없는 SELECT | `SELECT ... FROM table` (WHERE 없음) | 경고 |
|
|
69
|
-
| SELECT * | `SELECT * FROM ...` | 경고 |
|
|
70
|
-
| 집계 없는 대량 조회 | 조건 없이 raw 데이터 전체 조회 | 경고 |
|
|
71
|
-
|
|
72
|
-
### Category 3: 개인정보 (PII) — 기본: 경고
|
|
73
|
-
|
|
74
|
-
| 규칙 | 감지 컬럼명 키워드 | 기본 동작 |
|
|
75
|
-
|---|---|---|
|
|
76
|
-
| 민감 컬럼 SELECT | `email`, `phone`, `password`, `ssn`, `social`, `주민`, `휴대폰`, `핸드폰` | 경고 |
|
|
77
|
-
|
|
78
|
-
> PII 규칙은 컬럼명 기반 휴리스틱이며 오탐이 발생할 수 있다.
|
|
79
|
-
> 환경변수로 비활성화 가능.
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
## 동작 모드
|
|
84
|
-
|
|
85
|
-
```
|
|
86
|
-
REDASH_SAFETY_MODE=off|warn|strict
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
| 모드 | Destructive | Cost | PII |
|
|
90
|
-
|---|---|---|---|
|
|
91
|
-
| `off` | 통과 | 통과 | 통과 |
|
|
92
|
-
| `warn` (기본) | **차단** | 경고 후 실행 | 경고 후 실행 |
|
|
93
|
-
| `strict` | **차단** | **차단** | **차단** |
|
|
94
|
-
|
|
95
|
-
- Destructive는 모든 모드에서 `warn` 이상이면 차단 (데이터 손실은 되돌릴 수 없음)
|
|
96
|
-
- `off`는 완전히 비활성화가 필요한 경우를 위해 제공 (내부 관리자용 등)
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
## 환경변수
|
|
101
|
-
|
|
102
|
-
| 변수 | 기본값 | 설명 |
|
|
103
|
-
|---|---|---|
|
|
104
|
-
| `REDASH_SAFETY_MODE` | `warn` | 전체 안전 모드 |
|
|
105
|
-
| `REDASH_SAFETY_DISABLE_PII` | `false` | PII 감지 비활성화 |
|
|
106
|
-
| `REDASH_SAFETY_DISABLE_COST` | `false` | 비용 경고 비활성화 |
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
## 응답 포맷
|
|
111
|
-
|
|
112
|
-
### 차단 시
|
|
113
|
-
|
|
114
|
-
```
|
|
115
|
-
🚫 쿼리가 차단되었습니다.
|
|
116
|
-
|
|
117
|
-
사유: WHERE 조건 없이 전체 테이블을 수정하는 쿼리는 실행할 수 없습니다.
|
|
118
|
-
규칙: DESTRUCTIVE / UPDATE_WITHOUT_WHERE
|
|
119
|
-
|
|
120
|
-
안전한 쿼리 예시:
|
|
121
|
-
UPDATE orders SET status = 'cancelled' WHERE created_at < '2024-01-01'
|
|
122
|
-
|
|
123
|
-
안전 모드 해제가 필요하다면 REDASH_SAFETY_MODE=off 로 설정하세요.
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
### 경고 시 (warn 모드)
|
|
127
|
-
|
|
128
|
-
```
|
|
129
|
-
⚠️ 안전 경고 (쿼리는 실행됩니다)
|
|
130
|
-
|
|
131
|
-
[COST] SELECT *를 사용하고 있습니다. 필요한 컬럼만 명시하면 비용을 줄일 수 있습니다.
|
|
132
|
-
[COST] WHERE 조건이 없습니다. 날짜 또는 조건 필터를 추가하는 것을 권장합니다.
|
|
133
|
-
|
|
134
|
-
---
|
|
135
|
-
(쿼리 결과)
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
---
|
|
139
|
-
|
|
140
|
-
## 파일 구조
|
|
141
|
-
|
|
142
|
-
```
|
|
143
|
-
src/
|
|
144
|
-
index.ts # 기존 — run_query에 가드 연결
|
|
145
|
-
sql-guard.ts # 신규 — 안전 가드 핵심 로직
|
|
146
|
-
setup.ts # 기존 — 설치 마법사에 SAFETY_MODE 설정 추가
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
---
|
|
150
|
-
|
|
151
|
-
## setup 마법사 변경
|
|
152
|
-
|
|
153
|
-
```
|
|
154
|
-
? BigQuery 안전 모드를 선택하세요
|
|
155
|
-
○ off — 제한 없음 (관리자 전용)
|
|
156
|
-
● warn — 위험 패턴 경고 후 실행 (권장)
|
|
157
|
-
○ strict — 위험 쿼리 차단
|
|
158
|
-
|
|
159
|
-
? 개인정보(PII) 컬럼 감지를 활성화하시겠습니까?
|
|
160
|
-
● 예 / ○ 아니오
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
---
|
|
164
|
-
|
|
165
|
-
## 구현 범위 (v2.2.0)
|
|
166
|
-
|
|
167
|
-
### 포함
|
|
168
|
-
- [x] Destructive 쿼리 차단 (DDL, DELETE/UPDATE without WHERE)
|
|
169
|
-
- [x] Cost 경고/차단 (WHERE 없는 SELECT, SELECT *)
|
|
170
|
-
- [x] PII 컬럼 경고 (컬럼명 기반)
|
|
171
|
-
- [x] `REDASH_SAFETY_MODE` 환경변수
|
|
172
|
-
- [x] setup 마법사 안전 모드 설정 추가
|
|
173
|
-
- [x] 단위 테스트 (vitest)
|
|
174
|
-
|
|
175
|
-
### 제외 (추후 검토)
|
|
176
|
-
- Cartesian JOIN 감지 (SQL 파서 필요, 복잡도 높음)
|
|
177
|
-
- 쿼리 실행 횟수 기반 Rate limiting (상태 관리 필요)
|
|
178
|
-
- 실제 BQ 스캔 바이트 추정 (BQ 직접 접근 불가)
|
|
179
|
-
|
|
180
|
-
---
|
|
181
|
-
|
|
182
|
-
## 공식 플러그인 등록
|
|
183
|
-
|
|
184
|
-
### MCP 공식 레지스트리
|
|
185
|
-
- `modelcontextprotocol/servers` GitHub 레포에 PR 제출
|
|
186
|
-
- 요건: npm 패키지, README, 설치 가이드
|
|
187
|
-
|
|
188
|
-
### Claude Desktop
|
|
189
|
-
- 현재 Claude Desktop 플러그인 = MCP 서버
|
|
190
|
-
- 공식 디렉토리 등록은 Anthropic 신청 필요 (초대제 운영 중)
|
|
191
|
-
- 단기적으로는 MCP 커뮤니티 레지스트리 등록으로 대응
|
|
192
|
-
|
|
193
|
-
### 등록 준비 체크리스트
|
|
194
|
-
- [ ] README 영문화 (설치, 환경변수, 도구 목록)
|
|
195
|
-
- [ ] 라이선스 명시 (MIT)
|
|
196
|
-
- [ ] 버전 관리 전략 (semver)
|
|
197
|
-
- [ ] CHANGELOG 작성
|