redash-mcp 2.1.1 → 2.2.2

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/README.ko.md ADDED
@@ -0,0 +1,142 @@
1
+ # redash-mcp
2
+
3
+ [Redash](https://redash.io)를 Claude AI에 연결하는 MCP 서버 — 자연어로 데이터를 조회하고 대시보드를 관리하세요.
4
+
5
+ **[English Documentation](README.md)**
6
+
7
+ ---
8
+
9
+ ## 기능
10
+
11
+ ### 툴 목록
12
+
13
+ | 카테고리 | 툴 | 설명 |
14
+ |---|---|---|
15
+ | 데이터소스 | `list_data_sources` | 연결된 데이터소스 목록 조회 |
16
+ | 스키마 | `list_tables` | 테이블 목록 조회 (키워드 검색 가능) |
17
+ | 스키마 | `get_table_columns` | 테이블 컬럼명 및 타입 조회 |
18
+ | 쿼리 실행 | `run_query` | SQL 직접 실행 후 결과 반환 |
19
+ | 저장 쿼리 | `list_queries` | 저장된 쿼리 목록 조회 |
20
+ | 저장 쿼리 | `get_query` | 쿼리 상세 정보 (SQL, 시각화 등) 조회 |
21
+ | 저장 쿼리 | `get_query_result` | 저장된 쿼리 실행 결과 조회 |
22
+ | 저장 쿼리 | `create_query` | 새 쿼리 저장 |
23
+ | 저장 쿼리 | `update_query` | 쿼리 수정 |
24
+ | 저장 쿼리 | `fork_query` | 쿼리 복제 |
25
+ | 저장 쿼리 | `archive_query` | 쿼리 삭제 (아카이브) |
26
+ | 대시보드 | `list_dashboards` | 대시보드 목록 조회 |
27
+ | 대시보드 | `get_dashboard` | 대시보드 상세 및 위젯 목록 조회 |
28
+ | 대시보드 | `create_dashboard` | 새 대시보드 생성 |
29
+ | 대시보드 | `add_widget` | 대시보드에 시각화 위젯 추가 |
30
+ | 알림 | `list_alerts` | 알림 목록 조회 |
31
+ | 알림 | `get_alert` | 알림 상세 정보 조회 |
32
+ | 알림 | `create_alert` | 새 알림 생성 |
33
+
34
+ ### SQL 안전 가드
35
+
36
+ 위험한 쿼리로부터 데이터베이스를 보호합니다:
37
+
38
+ - **항상 차단**: `DROP`, `TRUNCATE`, `ALTER TABLE`, `GRANT/REVOKE`, `WHERE` 없는 `DELETE/UPDATE`
39
+ - **경고 (warn 모드)** / **차단 (strict 모드)**: `SELECT *`, `WHERE`·`LIMIT` 없는 쿼리, PII 컬럼 접근
40
+ - **자동 LIMIT**: `REDASH_AUTO_LIMIT` 설정 시 LIMIT 없는 쿼리에 자동으로 `LIMIT N` 추가
41
+
42
+ ### 쿼리 캐시
43
+
44
+ 중복 API 호출을 줄이기 위해 결과를 메모리에 캐싱합니다:
45
+
46
+ - TTL: `REDASH_MCP_CACHE_TTL` 환경변수로 설정 (기본값: 300초)
47
+ - 최대 메모리: `REDASH_MCP_CACHE_MAX_MB` 환경변수로 설정 (기본값: 50MB)
48
+
49
+ ---
50
+
51
+ ## 설치
52
+
53
+ ### 자동 설치 (권장)
54
+
55
+ ```bash
56
+ npx redash-mcp setup
57
+ ```
58
+
59
+ 설치 마법사가 실행되며 Claude Desktop, Claude Code(CLI), 또는 둘 다 선택하여 설정할 수 있습니다.
60
+
61
+ ### 수동 설치
62
+
63
+ #### 1. Redash API 키 발급
64
+
65
+ Redash → 우측 상단 프로필 → **Edit Profile** → **API Key** 복사
66
+
67
+ #### 2-A. Claude Desktop 설정
68
+
69
+ `~/Library/Application Support/Claude/claude_desktop_config.json` 파일을 열고 아래 내용을 추가합니다:
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "redash-mcp": {
75
+ "command": "npx",
76
+ "args": ["-y", "redash-mcp"],
77
+ "env": {
78
+ "REDASH_URL": "https://your-redash-instance.com",
79
+ "REDASH_API_KEY": "your_api_key_here"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ 저장 후 Claude Desktop을 완전히 종료했다가 다시 시작합니다.
87
+
88
+ #### 2-B. Claude Code (CLI) 설정
89
+
90
+ `~/.claude/settings.json` 파일을 열고 아래 내용을 추가합니다:
91
+
92
+ ```json
93
+ {
94
+ "mcpServers": {
95
+ "redash-mcp": {
96
+ "command": "npx",
97
+ "args": ["-y", "redash-mcp"],
98
+ "env": {
99
+ "REDASH_URL": "https://your-redash-instance.com",
100
+ "REDASH_API_KEY": "your_api_key_here"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ > **macOS**: `npx`를 못 찾는 경우 `which npx` 명령어로 전체 경로를 확인 후 대체하세요.
108
+
109
+ ---
110
+
111
+ ## 환경 변수
112
+
113
+ ### 필수
114
+
115
+ | 변수 | 설명 |
116
+ |---|---|
117
+ | `REDASH_URL` | Redash 인스턴스 URL (예: `https://redash.example.com`) |
118
+ | `REDASH_API_KEY` | Redash 사용자 API 키 |
119
+
120
+ ### 선택
121
+
122
+ | 변수 | 기본값 | 설명 |
123
+ |---|---|---|
124
+ | `REDASH_SAFETY_MODE` | `warn` | SQL 안전 수준: `off` / `warn` / `strict` |
125
+ | `REDASH_SAFETY_DISABLE_PII` | `false` | PII 감지 비활성화 |
126
+ | `REDASH_SAFETY_DISABLE_COST` | `false` | 비용 경고 비활성화 |
127
+ | `REDASH_AUTO_LIMIT` | `0` | LIMIT 없는 쿼리에 자동으로 `LIMIT N` 추가 (0 = 비활성화) |
128
+ | `REDASH_DEFAULT_MAX_AGE` | `0` | Redash 캐시 TTL (초) |
129
+ | `REDASH_MCP_CACHE_TTL` | `300` | MCP 쿼리 캐시 TTL (초, 0 = 비활성화) |
130
+ | `REDASH_MCP_CACHE_MAX_MB` | `50` | MCP 쿼리 캐시 최대 메모리 (MB) |
131
+
132
+ ---
133
+
134
+ ## 사용 예시
135
+
136
+ Claude에게 자연어로 요청하면 됩니다:
137
+
138
+ - "users 테이블 컬럼 보여줘"
139
+ - "최근 7일 주문 수를 SQL로 조회해줘"
140
+ - "저장된 쿼리 목록 보여줘"
141
+ - "매출 대시보드 위젯 목록 알려줘"
142
+ - "일별 가입자 수가 100명 이하로 떨어지면 알림 만들어줘"
package/README.md CHANGED
@@ -1,49 +1,72 @@
1
1
  # redash-mcp
2
2
 
3
- [Redash](https://redash.io) Claude AI에서 직접 조회하고 관리할 있는 MCP(Model Context Protocol) 서버입니다.
3
+ MCP server that connects [Redash](https://redash.io) to Claude AI query data, manage dashboards, and run SQL with natural language.
4
4
 
5
- ## 기능
5
+ **[한국어 문서](README.ko.md)**
6
6
 
7
- | 카테고리 | 툴 | 설명 |
7
+ ---
8
+
9
+ ## Features
10
+
11
+ ### Tools
12
+
13
+ | Category | Tool | Description |
8
14
  |---|---|---|
9
- | 데이터소스 | `list_data_sources` | 연결된 데이터소스 목록 조회 |
10
- | 스키마 | `list_tables` | 테이블 목록 조회 (키워드 검색 가능) |
11
- | 스키마 | `get_table_columns` | 테이블 컬럼명/타입 조회 |
12
- | 쿼리 실행 | `run_query` | SQL 직접 실행 결과 반환 |
13
- | 저장 쿼리 | `list_queries` | 저장된 쿼리 목록 조회 |
14
- | 저장 쿼리 | `get_query` | 쿼리 상세 정보(SQL, 시각화 등) 조회 |
15
- | 저장 쿼리 | `get_query_result` | 저장된 쿼리 실행 결과 조회 |
16
- | 저장 쿼리 | `create_query` | 쿼리 저장 |
17
- | 저장 쿼리 | `update_query` | 쿼리 수정 |
18
- | 저장 쿼리 | `fork_query` | 쿼리 복제 |
19
- | 저장 쿼리 | `archive_query` | 쿼리 삭제 |
20
- | 대시보드 | `list_dashboards` | 대시보드 목록 조회 |
21
- | 대시보드 | `get_dashboard` | 대시보드 상세 위젯 목록 조회 |
22
- | 대시보드 | `create_dashboard` | 대시보드 생성 |
23
- | 대시보드 | `add_widget` | 대시보드에 시각화 위젯 추가 |
24
- | 알림 | `list_alerts` | 알림 목록 조회 |
25
- | 알림 | `get_alert` | 알림 상세 정보 조회 |
26
- | 알림 | `create_alert` | 알림 생성 |
27
-
28
- ## 설치
29
-
30
- ### 자동 설치 (권장)
15
+ | Data Sources | `list_data_sources` | List connected data sources |
16
+ | Schema | `list_tables` | List tables (supports keyword search) |
17
+ | Schema | `get_table_columns` | Get column names and types |
18
+ | Query | `run_query` | Execute SQL and return results |
19
+ | Saved Queries | `list_queries` | List saved queries |
20
+ | Saved Queries | `get_query` | Get query details (SQL, visualizations) |
21
+ | Saved Queries | `get_query_result` | Run a saved query and get results |
22
+ | Saved Queries | `create_query` | Save a new query |
23
+ | Saved Queries | `update_query` | Update a saved query |
24
+ | Saved Queries | `fork_query` | Fork a saved query |
25
+ | Saved Queries | `archive_query` | Archive (delete) a query |
26
+ | Dashboards | `list_dashboards` | List dashboards |
27
+ | Dashboards | `get_dashboard` | Get dashboard details and widgets |
28
+ | Dashboards | `create_dashboard` | Create a new dashboard |
29
+ | Dashboards | `add_widget` | Add a visualization widget to a dashboard |
30
+ | Alerts | `list_alerts` | List alerts |
31
+ | Alerts | `get_alert` | Get alert details |
32
+ | Alerts | `create_alert` | Create a new alert |
33
+
34
+ ### SQL Safety Guard
35
+
36
+ Protects your database from dangerous queries:
37
+
38
+ - **Blocked always**: `DROP`, `TRUNCATE`, `ALTER TABLE`, `GRANT/REVOKE`, `DELETE/UPDATE` without `WHERE`
39
+ - **Warned (warn mode)** / **Blocked (strict mode)**: `SELECT *`, queries without `WHERE` or `LIMIT`, PII column access
40
+ - **Auto-LIMIT**: Automatically appends `LIMIT N` when `REDASH_AUTO_LIMIT` is set
41
+
42
+ ### Query Cache
43
+
44
+ Results are cached in-memory to reduce redundant API calls:
45
+
46
+ - TTL: configurable via `REDASH_MCP_CACHE_TTL` (default: 300s)
47
+ - Max memory: configurable via `REDASH_MCP_CACHE_MAX_MB` (default: 50MB)
48
+
49
+ ---
50
+
51
+ ## Installation
52
+
53
+ ### Auto Setup (Recommended)
31
54
 
32
55
  ```bash
33
56
  npx redash-mcp setup
34
57
  ```
35
58
 
36
- 설치 마법사가 실행되며 Claude Desktop, Claude Code(CLI), 또는 둘 다 선택하여 설정할 수 있습니다.
59
+ The setup wizard will guide you through configuring Claude Desktop, Claude Code (CLI), or both.
37
60
 
38
- ### 수동 설치
61
+ ### Manual Setup
39
62
 
40
- #### 1. Redash API 키 발급
63
+ #### 1. Get your Redash API Key
41
64
 
42
- Redash → 우측 상단 프로필 → **Edit Profile** → **API Key** 복사
65
+ Go to Redash → Profile (top right) → **Edit Profile** → Copy **API Key**
43
66
 
44
- #### 2-A. Claude Desktop 설정
67
+ #### 2-A. Claude Desktop
45
68
 
46
- `~/Library/Application Support/Claude/claude_desktop_config.json` 파일을 열고 아래 내용을 추가합니다:
69
+ Open `~/Library/Application Support/Claude/claude_desktop_config.json` and add:
47
70
 
48
71
  ```json
49
72
  {
@@ -60,11 +83,11 @@ Redash → 우측 상단 프로필 → **Edit Profile** → **API Key** 복사
60
83
  }
61
84
  ```
62
85
 
63
- 설정 저장 Claude Desktop 완전히 종료했다가 다시 시작합니다.
86
+ Fully quit and restart Claude Desktop after saving.
64
87
 
65
- #### 2-B. Claude Code (CLI) 설정
88
+ #### 2-B. Claude Code (CLI)
66
89
 
67
- `~/.claude/settings.json` 파일을 열고 아래 내용을 추가합니다:
90
+ Open `~/.claude/settings.json` and add:
68
91
 
69
92
  ```json
70
93
  {
@@ -81,23 +104,39 @@ Redash → 우측 상단 프로필 → **Edit Profile** → **API Key** 복사
81
104
  }
82
105
  ```
83
106
 
84
- Claude Code 세션에서 바로 사용할 있습니다.
107
+ > **macOS**: If `npx` is not found, run `which npx` to get the full path and use that instead.
85
108
 
86
- > **Windows/Linux**: `command`를 `npx`로 그대로 사용하면 됩니다.
87
- > **macOS**: `npx`를 못 찾는 경우 `which npx` 명령어로 전체 경로를 확인 후 대체하세요.
109
+ ---
88
110
 
89
- ## 환경 변수
111
+ ## Environment Variables
90
112
 
91
- | 변수 | 필수 | 설명 |
113
+ ### Required
114
+
115
+ | Variable | Description |
116
+ |---|---|
117
+ | `REDASH_URL` | Redash instance URL (e.g. `https://redash.example.com`) |
118
+ | `REDASH_API_KEY` | Redash user API key |
119
+
120
+ ### Optional
121
+
122
+ | Variable | Default | Description |
92
123
  |---|---|---|
93
- | `REDASH_URL` | | Redash 인스턴스 URL (예: `https://redash.example.com`) |
94
- | `REDASH_API_KEY` | | Redash 사용자 API |
124
+ | `REDASH_SAFETY_MODE` | `warn` | SQL safety level: `off` / `warn` / `strict` |
125
+ | `REDASH_SAFETY_DISABLE_PII` | `false` | Disable PII detection |
126
+ | `REDASH_SAFETY_DISABLE_COST` | `false` | Disable cost warnings |
127
+ | `REDASH_AUTO_LIMIT` | `0` | Auto-append `LIMIT N` to queries without one (0 = disabled) |
128
+ | `REDASH_DEFAULT_MAX_AGE` | `0` | Redash cache TTL in seconds |
129
+ | `REDASH_MCP_CACHE_TTL` | `300` | MCP query cache TTL in seconds (0 = disabled) |
130
+ | `REDASH_MCP_CACHE_MAX_MB` | `50` | Max memory for MCP query cache in MB |
131
+
132
+ ---
95
133
 
96
- ## 사용 예시
134
+ ## Usage Examples
97
135
 
98
- Claude에게 자연어로 요청하면 됩니다:
136
+ Just ask Claude in natural language:
99
137
 
100
- - "Redash에서 users 테이블 컬럼 보여줘"
101
- - "최근 7일 주문 수를 SQL로 조회해줘"
102
- - "저장된 쿼리 목록 보여줘"
103
- - "매출 대시보드 위젯 목록 알려줘"
138
+ - "Show me the columns in the users table"
139
+ - "Run a query to get order counts for the last 7 days"
140
+ - "List all saved queries"
141
+ - "Show widgets in the revenue dashboard"
142
+ - "Create an alert when daily signups drop below 100"
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
+ }
@@ -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.1",
3
+ "version": "2.2.2",
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": {