redash-mcp 2.1.0 → 2.2.1

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/.gitattributes ADDED
@@ -0,0 +1 @@
1
+ package-lock.json linguist-generated=true
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
+ import { analyzeQuery } from "./sql-guard.js";
6
+ import { getCached, setCached } from "./query-cache.js";
5
7
  if (process.argv[2] === "setup") {
6
8
  const { main } = await import("./setup.js");
7
9
  await main();
@@ -127,17 +129,52 @@ server.tool("get_table_columns", "테이블의 컬럼명과 타입을 반환합
127
129
  };
128
130
  });
129
131
  // ─── Query Execution ──────────────────────────────────────────────────────────
132
+ const DEFAULT_MAX_AGE = parseInt(process.env.REDASH_DEFAULT_MAX_AGE ?? "0", 10) || 0;
130
133
  server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과를 반환합니다. SQL 작성 전 list_tables → get_table_columns로 스키마를 먼저 확인하세요.", {
131
134
  data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
132
135
  query: z.string().describe("실행할 SQL 쿼리"),
133
- max_age: z.number().optional().default(0).describe("캐시 유지 시간(초), 0이면 항상 새로 실행"),
136
+ max_age: z.number().optional().describe("Redash 캐시 유지 시간(초). 미지정 REDASH_DEFAULT_MAX_AGE 환경변수 사용"),
134
137
  max_rows: z.number().optional().default(100).describe("반환할 최대 행 수 (기본 100)"),
135
138
  format: z.enum(["table", "json"]).optional().default("table").describe("결과 포맷: table(마크다운) 또는 json"),
136
139
  timeout_secs: z.number().optional().default(30).describe("쿼리 실행 타임아웃(초)"),
137
140
  }, async ({ data_source_id, query, max_age, max_rows, format, timeout_secs }) => {
141
+ // 1. SQL 안전 가드
142
+ const guard = analyzeQuery(query);
143
+ if (guard.blocked) {
144
+ return { content: [{ type: "text", text: guard.message }] };
145
+ }
146
+ // auto-LIMIT이 적용된 경우 변환된 쿼리 사용
147
+ const effectiveQuery = guard.modifiedQuery ?? query;
148
+ const effectiveMaxAge = max_age ?? DEFAULT_MAX_AGE;
149
+ // 2. MCP 레이어 캐시 조회
150
+ const cached = getCached(data_source_id, effectiveQuery);
151
+ if (cached) {
152
+ const { rows, columns, warningPrefix } = cached;
153
+ const displayRows = rows.slice(0, max_rows);
154
+ const truncated = rows.length > max_rows
155
+ ? `\n⚠️ 전체 ${rows.length}행 중 ${max_rows}행만 표시합니다.`
156
+ : "";
157
+ let body;
158
+ if (format === "json") {
159
+ body = JSON.stringify(displayRows, null, 2);
160
+ }
161
+ else {
162
+ body = formatAsMarkdownTable(columns, displayRows);
163
+ }
164
+ const cacheNote = "📦 MCP 캐시에서 반환된 결과입니다.\n\n";
165
+ return {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: `${warningPrefix}${cacheNote}총 ${rows.length}행 | 컬럼: ${columns.join(", ")}${truncated}\n\n${body}`,
170
+ },
171
+ ],
172
+ };
173
+ }
174
+ // 3. Redash API 호출
138
175
  const res = await redashFetch("/query_results", {
139
176
  method: "POST",
140
- body: JSON.stringify({ data_source_id, query, max_age }),
177
+ body: JSON.stringify({ data_source_id, query: effectiveQuery, max_age: effectiveMaxAge }),
141
178
  });
142
179
  let result;
143
180
  if (res.job) {
@@ -149,6 +186,9 @@ server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과
149
186
  const qr = result.query_result;
150
187
  const rows = qr.data.rows;
151
188
  const columns = qr.data.columns.map((c) => c.name);
189
+ // 4. MCP 캐시에 저장
190
+ const warningPrefix = guard.message ? `${guard.message}\n\n` : "";
191
+ setCached(data_source_id, effectiveQuery, { rows, columns, warningPrefix });
152
192
  const displayRows = rows.slice(0, max_rows);
153
193
  const truncated = rows.length > max_rows
154
194
  ? `\n⚠️ 전체 ${rows.length}행 중 ${max_rows}행만 표시합니다.`
@@ -164,7 +204,7 @@ server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과
164
204
  content: [
165
205
  {
166
206
  type: "text",
167
- text: `총 ${rows.length}행 | 컬럼: ${columns.join(", ")}${truncated}\n\n${body}`,
207
+ text: `${warningPrefix}총 ${rows.length}행 | 컬럼: ${columns.join(", ")}${truncated}\n\n${body}`,
168
208
  },
169
209
  ],
170
210
  };
@@ -0,0 +1,74 @@
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
+ export function getCached(dataSourceId, sql) {
29
+ const ttl = getCacheTtlMs();
30
+ if (ttl === 0)
31
+ return null;
32
+ const key = makeCacheKey(dataSourceId, sql);
33
+ const entry = cache.get(key);
34
+ if (!entry)
35
+ return null;
36
+ if (Date.now() - entry.ts > ttl) {
37
+ totalSizeBytes -= entry.size;
38
+ cache.delete(key);
39
+ return null;
40
+ }
41
+ return entry.result;
42
+ }
43
+ export function setCached(dataSourceId, sql, result) {
44
+ const ttl = getCacheTtlMs();
45
+ if (ttl === 0)
46
+ return;
47
+ const maxSize = getMaxSizeBytes();
48
+ const size = roughSize(result);
49
+ // 단일 결과가 전체 한도의 20% 초과 시 캐시하지 않음
50
+ if (size > maxSize * 0.2)
51
+ return;
52
+ // 한도 초과 시 오래된 항목부터 제거
53
+ while (totalSizeBytes + size > maxSize && cache.size > 0) {
54
+ const oldestKey = cache.keys().next().value;
55
+ if (!oldestKey)
56
+ break;
57
+ const old = cache.get(oldestKey);
58
+ totalSizeBytes -= old.size;
59
+ cache.delete(oldestKey);
60
+ }
61
+ const key = makeCacheKey(dataSourceId, sql);
62
+ const existing = cache.get(key);
63
+ if (existing)
64
+ totalSizeBytes -= existing.size;
65
+ cache.set(key, { result, ts: Date.now(), size });
66
+ totalSizeBytes += size;
67
+ }
68
+ export function getCacheStats() {
69
+ return {
70
+ entries: cache.size,
71
+ sizeMb: (totalSizeBytes / 1024 / 1024).toFixed(2),
72
+ ttlSecs: getCacheTtlMs() / 1000,
73
+ };
74
+ }
package/dist/setup.js CHANGED
@@ -76,6 +76,65 @@ export async function main() {
76
76
  p.cancel("설치가 취소되었습니다.");
77
77
  process.exit(0);
78
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("설치가 취소되었습니다.");
136
+ process.exit(0);
137
+ }
79
138
  const url = redashUrl.replace(/\/$/, "");
80
139
  const npxPath = findNpxPath();
81
140
  const mcpEntry = {
@@ -84,6 +143,10 @@ export async function main() {
84
143
  env: {
85
144
  REDASH_URL: url,
86
145
  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"),
87
150
  },
88
151
  };
89
152
  const s = p.spinner();
@@ -0,0 +1,159 @@
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
+ // ── Destructive (차단: warn/strict 모두) ────────────────────────────────────
44
+ if (/\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW|INDEX|FUNCTION)\b/.test(upper)) {
45
+ return {
46
+ blocked: true,
47
+ warnings: [],
48
+ message: "🚫 쿼리가 차단되었습니다.\n\n사유: DROP 문은 데이터/스키마를 영구 삭제합니다.\n규칙: DESTRUCTIVE / DROP\n\n해제가 필요하다면 REDASH_SAFETY_MODE=off 로 설정하세요.",
49
+ };
50
+ }
51
+ if (/\bTRUNCATE\b/.test(upper)) {
52
+ return {
53
+ blocked: true,
54
+ warnings: [],
55
+ message: "🚫 쿼리가 차단되었습니다.\n\n사유: TRUNCATE 문은 전체 테이블 데이터를 삭제합니다.\n규칙: DESTRUCTIVE / TRUNCATE",
56
+ };
57
+ }
58
+ if (/\bALTER\s+TABLE\b/.test(upper)) {
59
+ return {
60
+ blocked: true,
61
+ warnings: [],
62
+ message: "🚫 쿼리가 차단되었습니다.\n\n사유: ALTER TABLE은 스키마 변경으로 사전 협의가 필요합니다.\n규칙: DESTRUCTIVE / ALTER_TABLE",
63
+ };
64
+ }
65
+ if (/\b(GRANT|REVOKE)\b/.test(upper)) {
66
+ return {
67
+ blocked: true,
68
+ warnings: [],
69
+ message: "🚫 쿼리가 차단되었습니다.\n\n사유: GRANT/REVOKE는 권한 변경으로 허용되지 않습니다.\n규칙: DESTRUCTIVE / PRIVILEGE_CHANGE",
70
+ };
71
+ }
72
+ if (/\bDELETE\s+FROM\b/.test(upper) && !hasWhere(upper)) {
73
+ return {
74
+ blocked: true,
75
+ warnings: [],
76
+ message: "🚫 쿼리가 차단되었습니다.\n\n사유: WHERE 조건 없는 DELETE는 전체 데이터를 삭제합니다.\n규칙: DESTRUCTIVE / DELETE_WITHOUT_WHERE\n\n안전한 예시:\n DELETE FROM orders WHERE created_at < '2024-01-01'",
77
+ };
78
+ }
79
+ if (/\bUPDATE\b/.test(upper) && /\bSET\b/.test(upper) && !hasWhere(upper)) {
80
+ return {
81
+ blocked: true,
82
+ warnings: [],
83
+ message: "🚫 쿼리가 차단되었습니다.\n\n사유: WHERE 조건 없는 UPDATE는 전체 데이터를 수정합니다.\n규칙: DESTRUCTIVE / UPDATE_WITHOUT_WHERE\n\n안전한 예시:\n UPDATE orders SET status = 'cancelled' WHERE created_at < '2024-01-01'",
84
+ };
85
+ }
86
+ // DELETE/UPDATE with WHERE — 경고만
87
+ if (/\bDELETE\s+FROM\b/.test(upper)) {
88
+ warnings.push("[DESTRUCTIVE] DELETE 쿼리입니다. WHERE 조건을 다시 확인하세요.");
89
+ }
90
+ if (/\bUPDATE\b/.test(upper) && /\bSET\b/.test(upper)) {
91
+ warnings.push("[DESTRUCTIVE] UPDATE 쿼리입니다. WHERE 조건을 다시 확인하세요.");
92
+ }
93
+ // ── Cost (warn: 경고 후 실행, strict: 차단) ────────────────────────────────
94
+ if (!config.disableCost && isSelect(sql)) {
95
+ const hasSelectStar = /SELECT\s+\*/.test(upper) || /SELECT\s+[\w.]+\.\*/.test(upper);
96
+ const noWhere = !hasWhere(upper);
97
+ const noLimit = !hasLimit(upper);
98
+ if (hasSelectStar) {
99
+ warnings.push("[COST] SELECT *를 사용하고 있습니다. 필요한 컬럼만 명시하면 BigQuery 스캔 비용을 줄일 수 있습니다.");
100
+ }
101
+ if (noWhere) {
102
+ warnings.push("[COST] WHERE 조건이 없습니다. 날짜 또는 조건 필터를 추가하는 것을 권장합니다.");
103
+ }
104
+ if (noLimit) {
105
+ if (config.autoLimit > 0) {
106
+ modifiedQuery = injectLimit(sql, config.autoLimit);
107
+ warnings.push(`[COST] LIMIT이 없어 자동으로 LIMIT ${config.autoLimit}을 추가했습니다. 전체 조회가 필요하면 명시적으로 LIMIT을 지정하세요.`);
108
+ }
109
+ else {
110
+ warnings.push("[COST] LIMIT이 없습니다. 대용량 테이블에서는 전체 데이터가 반환되어 비용이 발생할 수 있습니다.");
111
+ }
112
+ }
113
+ if (config.mode === "strict") {
114
+ const costWarnings = warnings.filter((w) => w.startsWith("[COST]"));
115
+ if (costWarnings.length > 0) {
116
+ return {
117
+ blocked: true,
118
+ warnings: [],
119
+ message: `🚫 쿼리가 차단되었습니다 (strict 모드).\n\n${costWarnings.join("\n")}\n\nwarn 모드로 변경하려면 REDASH_SAFETY_MODE=warn 으로 설정하세요.`,
120
+ };
121
+ }
122
+ }
123
+ }
124
+ // ── PII (warn: 경고 후 실행, strict: 차단) ────────────────────────────────
125
+ if (!config.disablePii) {
126
+ const piiPatterns = [
127
+ "EMAIL",
128
+ "PHONE",
129
+ "PASSWORD",
130
+ "PASSWD",
131
+ "SSN",
132
+ "SOCIAL_SECURITY",
133
+ "CREDIT_CARD",
134
+ "CARD_NUMBER",
135
+ "주민",
136
+ "휴대폰",
137
+ "핸드폰",
138
+ "생년월일",
139
+ ];
140
+ const matched = piiPatterns.filter((k) => upper.includes(k));
141
+ if (matched.length > 0) {
142
+ warnings.push(`[PII] 민감 정보 관련 컬럼이 감지되었습니다: ${matched.join(", ")}. 개인정보 처리 규정을 확인하세요.`);
143
+ }
144
+ if (config.mode === "strict") {
145
+ const piiWarnings = warnings.filter((w) => w.startsWith("[PII]"));
146
+ if (piiWarnings.length > 0) {
147
+ return {
148
+ blocked: true,
149
+ warnings: [],
150
+ message: `🚫 쿼리가 차단되었습니다 (strict 모드).\n\n${piiWarnings.join("\n")}\n\nwarn 모드로 변경하려면 REDASH_SAFETY_MODE=warn 으로 설정하세요.`,
151
+ };
152
+ }
153
+ }
154
+ }
155
+ const message = warnings.length > 0
156
+ ? `⚠️ 안전 경고 (쿼리는 실행됩니다)\n\n${warnings.join("\n")}\n\n---`
157
+ : "";
158
+ return { blocked: false, warnings, message, modifiedQuery };
159
+ }
@@ -0,0 +1,197 @@
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 작성
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "redash-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "MCP server for Redash",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/seob717/redash-mcp.git"
8
+ },
9
+ "homepage": "https://github.com/seob717/redash-mcp#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/seob717/redash-mcp/issues"
12
+ },
5
13
  "type": "module",
6
14
  "main": "dist/index.js",
7
15
  "bin": {
@@ -1,101 +0,0 @@
1
- {
2
- "version": "1.0.0",
3
- "lastScanned": 1772697803610,
4
- "projectRoot": "/Users/jiro/WebstormProjects/jiro-tools/redash-mcp",
5
- "techStack": {
6
- "languages": [
7
- {
8
- "name": "JavaScript/TypeScript",
9
- "version": null,
10
- "confidence": "high",
11
- "markers": [
12
- "package.json"
13
- ]
14
- },
15
- {
16
- "name": "TypeScript",
17
- "version": null,
18
- "confidence": "high",
19
- "markers": [
20
- "tsconfig.json"
21
- ]
22
- }
23
- ],
24
- "frameworks": [],
25
- "packageManager": "npm",
26
- "runtime": null
27
- },
28
- "build": {
29
- "buildCommand": "npm run build",
30
- "testCommand": null,
31
- "lintCommand": null,
32
- "devCommand": "npm run dev",
33
- "scripts": {
34
- "build": "tsc",
35
- "dev": "tsx src/index.ts",
36
- "start": "node dist/index.js"
37
- }
38
- },
39
- "conventions": {
40
- "namingStyle": "camelCase",
41
- "importStyle": "ES modules",
42
- "testPattern": null,
43
- "fileOrganization": null
44
- },
45
- "structure": {
46
- "isMonorepo": false,
47
- "workspaces": [],
48
- "mainDirectories": [
49
- "src"
50
- ],
51
- "gitBranches": null
52
- },
53
- "customNotes": [],
54
- "directoryMap": {
55
- "dist": {
56
- "path": "dist",
57
- "purpose": "Distribution/build output",
58
- "fileCount": 1,
59
- "lastAccessed": 1772697803604,
60
- "keyFiles": [
61
- "index.js"
62
- ]
63
- },
64
- "src": {
65
- "path": "src",
66
- "purpose": "Source code",
67
- "fileCount": 1,
68
- "lastAccessed": 1772697803604,
69
- "keyFiles": [
70
- "index.ts"
71
- ]
72
- }
73
- },
74
- "hotPaths": [
75
- {
76
- "path": "src/index.ts",
77
- "accessCount": 23,
78
- "lastAccessed": 1772762087285,
79
- "type": "file"
80
- },
81
- {
82
- "path": "package.json",
83
- "accessCount": 18,
84
- "lastAccessed": 1772761975801,
85
- "type": "file"
86
- },
87
- {
88
- "path": "src/setup.ts",
89
- "accessCount": 9,
90
- "lastAccessed": 1772763077204,
91
- "type": "file"
92
- },
93
- {
94
- "path": "",
95
- "accessCount": 8,
96
- "lastAccessed": 1772760488146,
97
- "type": "directory"
98
- }
99
- ],
100
- "userDirectives": []
101
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "0450b0c4-f994-42f3-afd3-8f1b23f25ad6",
3
- "ended_at": "2026-03-05T08:30:17.840Z",
4
- "reason": "other",
5
- "agents_spawned": 4,
6
- "agents_completed": 4,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "0cb3ce10-4e01-4426-8d8f-e76668bcf8eb",
3
- "ended_at": "2026-03-05T09:54:42.407Z",
4
- "reason": "clear",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "1eaa9d6f-a106-4c32-aad1-96ea536bbc23",
3
- "ended_at": "2026-03-06T01:06:41.433Z",
4
- "reason": "other",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "3a248a8e-825c-4cb7-980b-8b6ef4c75fb6",
3
- "ended_at": "2026-03-05T09:05:45.287Z",
4
- "reason": "other",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "42043e3e-2800-407f-b1ab-e17206a826e2",
3
- "ended_at": "2026-03-05T07:51:30.914Z",
4
- "reason": "clear",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "441cb2ce-7778-43b1-aad0-6f528ce067f5",
3
- "ended_at": "2026-03-05T09:04:58.705Z",
4
- "reason": "other",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "4a7d0bb3-4702-49c3-ad7d-3a0936365e46",
3
- "ended_at": "2026-03-06T01:52:47.495Z",
4
- "reason": "clear",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "51fddb19-06a4-4491-b751-e46b5d7e04c4",
3
- "ended_at": "2026-03-06T01:06:42.394Z",
4
- "reason": "other",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "85f46695-2555-4c3d-9b7b-8ed9ab03083a",
3
- "ended_at": "2026-03-05T08:23:26.273Z",
4
- "reason": "clear",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "session_id": "bce5f4fd-c242-4074-b005-37abb59f88d8",
3
- "ended_at": "2026-03-05T04:31:50.081Z",
4
- "reason": "other",
5
- "agents_spawned": 0,
6
- "agents_completed": 0,
7
- "modes_used": []
8
- }
@@ -1,11 +0,0 @@
1
- {"t":0,"agent":"system","event":"keyword_detected","keyword":"ultrawork"}
2
- {"t":0,"agent":"system","event":"mode_change","mode_from":"none","mode_to":"ultrawork"}
3
- {"t":0,"agent":"af83682","agent_type":"executor","event":"agent_start","parent_mode":"none"}
4
- {"t":0,"agent":"a5125cc","agent_type":"executor","event":"agent_start","parent_mode":"none"}
5
- {"t":0,"agent":"a5d466c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
6
- {"t":0,"agent":"a5125cc","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":19049}
7
- {"t":0,"agent":"ae89220","agent_type":"executor","event":"agent_start","parent_mode":"none"}
8
- {"t":0,"agent":"af83682","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":53629}
9
- {"t":0,"agent":"ae89220","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":29751}
10
- {"t":0,"agent":"a5d466c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":43278}
11
- {"t":0,"agent":"system","event":"skill_invoked","skill_name":"oh-my-claudecode:cancel"}
@@ -1 +0,0 @@
1
- {"t":0,"agent":"system","event":"skill_invoked","skill_name":"oh-my-claudecode:skill"}
@@ -1,6 +0,0 @@
1
- {
2
- "timestamp": "2026-03-06T01:52:49.266Z",
3
- "backgroundTasks": [],
4
- "sessionStartTimestamp": "2026-03-06T01:52:47.508Z",
5
- "sessionId": "af950aa1-02a2-433d-bf22-7ae432356e6a"
6
- }
@@ -1 +0,0 @@
1
- {"session_id":"af950aa1-02a2-433d-bf22-7ae432356e6a","transcript_path":"/Users/jiro/.claude/projects/-Users-jiro-WebstormProjects-jiro-tools-redash-mcp/af950aa1-02a2-433d-bf22-7ae432356e6a.jsonl","cwd":"/Users/jiro/WebstormProjects/jiro-tools/redash-mcp","model":{"id":"claude-sonnet-4-6","display_name":"Sonnet 4.6"},"workspace":{"current_dir":"/Users/jiro/WebstormProjects/jiro-tools/redash-mcp","project_dir":"/Users/jiro/WebstormProjects/jiro-tools/redash-mcp","added_dirs":["/Users/jiro/.claude/skills/omc-learned"]},"version":"2.1.69","output_style":{"name":"default"},"cost":{"total_cost_usd":3.3345675000000017,"total_duration_ms":3892454,"total_api_duration_ms":594144,"total_lines_added":271,"total_lines_removed":22},"context_window":{"total_input_tokens":71068,"total_output_tokens":28846,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":139,"cache_creation_input_tokens":157,"cache_read_input_tokens":52142},"used_percentage":26,"remaining_percentage":74},"exceeds_200k_tokens":false}
@@ -1,3 +0,0 @@
1
- {
2
- "lastSentAt": "2026-03-06T02:16:56.279Z"
3
- }
@@ -1,7 +0,0 @@
1
- {
2
- "tool_name": "Bash",
3
- "tool_input_preview": "{\"command\":\"NPM_TOKEN=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 npm whoami --registry=https://registry.npmjs.org/ 2>&1\",\"description\":\"토큰으로 npm 인증 확인\"}",
4
- "error": "Exit code 1\nnpm error code E401\nnpm error 401 Unauthorized - GET https://registry.npmjs.org/-/whoami\nnpm error A complete log of this run can be found in: /Users/jiro/.npm/_logs/2026-03-06T02_18_25_687Z-debug-0.log\n\nnpm error code E401\nnpm error 401 Unauthorized - GET https://registry.npmjs.org/-/whoami\nnpm error A complete log of this run can be found in: /Users/jiro/.npm/_logs/2026-03-06T02_18_25_687Z-debug-0.log",
5
- "timestamp": "2026-03-06T02:18:26.057Z",
6
- "retry_count": 1
7
- }
@@ -1,7 +0,0 @@
1
- {
2
- "active": true,
3
- "requested_at": "2026-03-05T09:05:10.781Z",
4
- "expires_at": "2026-03-05T09:05:40.781Z",
5
- "mode": "ultrawork",
6
- "source": "state_clear"
7
- }