redash-mcp 1.0.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/.claude/settings.local.json +8 -0
- package/dist/index.js +181 -0
- package/package.json +20 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const REDASH_URL = process.env.REDASH_URL?.replace(/\/$/, "");
|
|
5
|
+
const REDASH_API_KEY = process.env.REDASH_API_KEY;
|
|
6
|
+
if (!REDASH_URL || !REDASH_API_KEY) {
|
|
7
|
+
console.error("REDASH_URL and REDASH_API_KEY environment variables are required");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
async function redashFetch(path, options) {
|
|
11
|
+
const res = await fetch(`${REDASH_URL}/api${path}`, {
|
|
12
|
+
...options,
|
|
13
|
+
headers: {
|
|
14
|
+
"Authorization": `Key ${REDASH_API_KEY}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
...options?.headers,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(`Redash API error: ${res.status} ${res.statusText}`);
|
|
21
|
+
}
|
|
22
|
+
return res.json();
|
|
23
|
+
}
|
|
24
|
+
async function pollQueryResult(jobId) {
|
|
25
|
+
for (let i = 0; i < 30; i++) {
|
|
26
|
+
const job = await redashFetch(`/jobs/${jobId}`);
|
|
27
|
+
if (job.job.status === 3) {
|
|
28
|
+
return await redashFetch(`/query_results/${job.job.query_result_id}`);
|
|
29
|
+
}
|
|
30
|
+
if (job.job.status === 4) {
|
|
31
|
+
throw new Error(`Query failed: ${job.job.error}`);
|
|
32
|
+
}
|
|
33
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
34
|
+
}
|
|
35
|
+
throw new Error("Query timed out");
|
|
36
|
+
}
|
|
37
|
+
// Schema cache: data_source_id → { schema, timestamp }
|
|
38
|
+
const schemaCache = new Map();
|
|
39
|
+
const CACHE_TTL_MS = 10 * 60 * 1000; // 10분
|
|
40
|
+
async function fetchSchema(dataSourceId) {
|
|
41
|
+
const cached = schemaCache.get(dataSourceId);
|
|
42
|
+
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
|
|
43
|
+
return cached.schema;
|
|
44
|
+
}
|
|
45
|
+
const result = await redashFetch(`/data_sources/${dataSourceId}/schema`);
|
|
46
|
+
const schema = result.schema ?? [];
|
|
47
|
+
schemaCache.set(dataSourceId, { schema, ts: Date.now() });
|
|
48
|
+
return schema;
|
|
49
|
+
}
|
|
50
|
+
const server = new McpServer({
|
|
51
|
+
name: "redash-mcp",
|
|
52
|
+
version: "2.0.0",
|
|
53
|
+
});
|
|
54
|
+
// Tool: list data sources
|
|
55
|
+
server.tool("list_data_sources", "Redash에 연결된 데이터소스 목록(id, name, type)을 반환합니다. 항상 이 툴을 먼저 호출해 data_source_id를 확인하세요.", {}, async () => {
|
|
56
|
+
const data = await redashFetch("/data_sources");
|
|
57
|
+
const sources = data.map((ds) => ({
|
|
58
|
+
id: ds.id,
|
|
59
|
+
name: ds.name,
|
|
60
|
+
type: ds.type,
|
|
61
|
+
}));
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: JSON.stringify(sources, null, 2) }],
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
// Tool: list tables
|
|
67
|
+
server.tool("list_tables", "데이터소스의 테이블 목록을 반환합니다. keyword로 관련 테이블을 검색할 수 있습니다. SQL 작성 전 반드시 이 툴로 테이블명을 확인하고, get_table_columns로 컬럼을 확인하세요.", {
|
|
68
|
+
data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
|
|
69
|
+
keyword: z.string().optional().describe("테이블명 검색 키워드 (예: 'user', 'order')"),
|
|
70
|
+
}, async ({ data_source_id, keyword }) => {
|
|
71
|
+
const schema = await fetchSchema(data_source_id);
|
|
72
|
+
let tables = schema.map((t) => t.name);
|
|
73
|
+
if (keyword) {
|
|
74
|
+
tables = tables.filter((name) => name.toLowerCase().includes(keyword.toLowerCase()));
|
|
75
|
+
}
|
|
76
|
+
const summary = `총 ${tables.length}개 테이블${keyword ? ` ('${keyword}' 포함)` : ""}\n\n${tables.join("\n")}`;
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: summary }],
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
// Tool: get table columns
|
|
82
|
+
server.tool("get_table_columns", "특정 테이블의 컬럼명과 타입을 반환합니다. SQL 작성 전 실제 컬럼명을 반드시 확인하세요. 확인 후 run_query로 SQL을 실행하세요.", {
|
|
83
|
+
data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
|
|
84
|
+
table_name: z.string().describe("list_tables로 확인한 테이블명"),
|
|
85
|
+
}, async ({ data_source_id, table_name }) => {
|
|
86
|
+
const schema = await fetchSchema(data_source_id);
|
|
87
|
+
let table = schema.find((t) => t.name.toLowerCase() === table_name.toLowerCase());
|
|
88
|
+
if (!table) {
|
|
89
|
+
table = schema.find((t) => t.name.toLowerCase().includes(table_name.toLowerCase()));
|
|
90
|
+
}
|
|
91
|
+
if (!table) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: `테이블 '${table_name}'을 찾을 수 없습니다. list_tables로 정확한 테이블명을 확인하세요.` }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const cols = (table.columns ?? []).map((c) => `${c.name} (${c.type ?? "unknown"})`).join("\n");
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: `[${table.name}]\n${cols}` }],
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
// Tool: run query
|
|
102
|
+
server.tool("run_query", "SQL을 데이터소스에 직접 실행하고 결과를 반환합니다. SQL 작성 전 list_tables → get_table_columns로 스키마를 먼저 확인하세요.", {
|
|
103
|
+
data_source_id: z.number().describe("list_data_sources로 확인한 데이터소스 ID"),
|
|
104
|
+
query: z.string().describe("실행할 SQL 쿼리"),
|
|
105
|
+
max_age: z.number().optional().default(0).describe("캐시 유지 시간(초), 0이면 항상 새로 실행"),
|
|
106
|
+
}, async ({ data_source_id, query, max_age }) => {
|
|
107
|
+
const res = await redashFetch("/query_results", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
body: JSON.stringify({ data_source_id, query, max_age }),
|
|
110
|
+
});
|
|
111
|
+
let result;
|
|
112
|
+
if (res.job) {
|
|
113
|
+
result = await pollQueryResult(res.job.id);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
result = res;
|
|
117
|
+
}
|
|
118
|
+
const qr = result.query_result;
|
|
119
|
+
const rows = qr.data.rows;
|
|
120
|
+
const columns = qr.data.columns.map((c) => c.name);
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: `총 ${rows.length}행\n컬럼: ${columns.join(", ")}\n\n${JSON.stringify(rows, null, 2)}`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
// Tool: list saved queries
|
|
131
|
+
server.tool("list_queries", "Redash에 저장된 쿼리 목록을 조회합니다.", {
|
|
132
|
+
search: z.string().optional().describe("검색어"),
|
|
133
|
+
page: z.number().optional().default(1),
|
|
134
|
+
page_size: z.number().optional().default(20),
|
|
135
|
+
}, async ({ search, page, page_size }) => {
|
|
136
|
+
const params = new URLSearchParams({
|
|
137
|
+
page: String(page),
|
|
138
|
+
page_size: String(page_size),
|
|
139
|
+
...(search ? { q: search } : {}),
|
|
140
|
+
});
|
|
141
|
+
const data = await redashFetch(`/queries?${params}`);
|
|
142
|
+
const queries = data.results.map((q) => ({
|
|
143
|
+
id: q.id,
|
|
144
|
+
name: q.name,
|
|
145
|
+
description: q.description,
|
|
146
|
+
data_source_id: q.data_source_id,
|
|
147
|
+
updated_at: q.updated_at,
|
|
148
|
+
}));
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text", text: JSON.stringify(queries, null, 2) }],
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
// Tool: get saved query result
|
|
154
|
+
server.tool("get_query_result", "저장된 Redash 쿼리를 ID로 실행하고 결과를 반환합니다.", {
|
|
155
|
+
query_id: z.number().describe("저장된 쿼리 ID (list_queries로 확인)"),
|
|
156
|
+
}, async ({ query_id }) => {
|
|
157
|
+
const res = await redashFetch(`/queries/${query_id}/results`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: JSON.stringify({}),
|
|
160
|
+
});
|
|
161
|
+
let result;
|
|
162
|
+
if (res.job) {
|
|
163
|
+
result = await pollQueryResult(res.job.id);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
result = res;
|
|
167
|
+
}
|
|
168
|
+
const qr = result.query_result;
|
|
169
|
+
const rows = qr.data.rows;
|
|
170
|
+
const columns = qr.data.columns.map((c) => c.name);
|
|
171
|
+
return {
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: "text",
|
|
175
|
+
text: `쿼리 ID: ${query_id}\n총 ${rows.length}행\n컬럼: ${columns.join(", ")}\n\n${JSON.stringify(rows, null, 2)}`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const transport = new StdioServerTransport();
|
|
181
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "redash-mcp",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "MCP server for Redash",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsx src/index.ts",
|
|
10
|
+
"start": "node dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.0.0",
|
|
17
|
+
"tsx": "^4.0.0",
|
|
18
|
+
"typescript": "^5.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|