cq-mcp-server 0.1.0 → 0.3.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/README.md +52 -3
- package/dist/index.js +315 -215
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,19 +1,68 @@
|
|
|
1
1
|
# CloudQuery MCP Server 使用指南
|
|
2
2
|
|
|
3
|
-
本 MCP Server 连接 CloudQuery
|
|
3
|
+
本 MCP Server 连接 CloudQuery 平台,提供五个工具,让 AI 能够探索数据库结构并执行 SQL 查询。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g cq-mcp-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 运行模式
|
|
12
|
+
|
|
13
|
+
### stdio 模式(Claude Desktop / OpenCode)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 直接运行,默认 stdio
|
|
17
|
+
CQ_BASE_URL=http://10.10.2.73 CQ_USERNAME=admin001 CQ_PASSWORD=Hello123$ cq-mcp-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### HTTP 模式(Dify 接入)
|
|
21
|
+
|
|
22
|
+
Dify 只支持 HTTP 传输,通过环境变量切换:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
MCP_TRANSPORT=http MCP_PORT=8080 cq-mcp-server
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
启动后 MCP 端点为:`http://<服务器IP>:8080/mcp`
|
|
29
|
+
|
|
30
|
+
**Dify 添加步骤:**
|
|
31
|
+
1. Dify → Tools → MCP → Add MCP Server (HTTP)
|
|
32
|
+
2. Server URL 填:`http://<服务器IP>:8080/mcp`
|
|
33
|
+
3. 确认后 Dify 自动发现 `connect` / `list_databases` / `list_tables` / `get_table_columns` / `execute_sql` 五个工具
|
|
34
|
+
|
|
35
|
+
**HTTP 模式下的认证**:无需配置环境变量,在 workflow 中第一步调用 `connect` 工具传入地址和账密即可(适合多租户场景)。
|
|
36
|
+
|
|
37
|
+
---
|
|
4
38
|
|
|
5
39
|
## 工具调用顺序
|
|
6
40
|
|
|
7
41
|
```
|
|
8
|
-
list_databases → list_tables → get_table_columns → execute_sql
|
|
42
|
+
connect(可选)→ list_databases → list_tables → get_table_columns → execute_sql
|
|
9
43
|
```
|
|
10
44
|
|
|
11
|
-
|
|
45
|
+
- **`connect`**:动态传入连接参数时需要先调用(如 Dify 等场景)。若已通过环境变量 `CQ_BASE_URL` / `CQ_USERNAME` / `CQ_PASSWORD` 配置,可跳过。
|
|
46
|
+
- 后续工具所需的 `connection_id`、`connection_type`、`database`、`schema` 参数,都只能从前面的工具返回结果中获取,不能凭空猜测。
|
|
12
47
|
|
|
13
48
|
---
|
|
14
49
|
|
|
15
50
|
## 工具详解
|
|
16
51
|
|
|
52
|
+
### 0. `connect` — 设置连接参数(动态场景必须首先调用)
|
|
53
|
+
|
|
54
|
+
| 参数 | 说明 |
|
|
55
|
+
|------|------|
|
|
56
|
+
| `base_url` | CloudQuery 平台地址,如 `http://10.10.2.73` |
|
|
57
|
+
| `username` | 登录账号 |
|
|
58
|
+
| `password` | 登录密码(明文,服务端自动加密) |
|
|
59
|
+
|
|
60
|
+
返回:连接配置成功确认。调用后原 Session 失效,下次请求自动重新登录。
|
|
61
|
+
|
|
62
|
+
**Dify 配置示例**:在 workflow 开始节点调用 `connect`,将用户输入的 `base_url` / `username` / `password` 传入,后续工具调用无需再传。
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
17
66
|
### 1. `list_databases` — 查询数据库清单
|
|
18
67
|
|
|
19
68
|
**无需任何参数。**
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
4
7
|
import { z } from "zod";
|
|
5
8
|
import forge from "node-forge";
|
|
6
|
-
const BASE_URL = process.env.CQ_BASE_URL ?? "http://10.10.2.73";
|
|
7
|
-
const USERNAME = process.env.CQ_USERNAME ?? "admin001";
|
|
8
|
-
const PASSWORD = process.env.CQ_PASSWORD ?? "Hello123$";
|
|
9
9
|
// MCP 协议通过 stdio 通信,日志必须输出到 stderr 避免污染 stdout
|
|
10
10
|
const log = {
|
|
11
11
|
info: (...args) => process.stderr.write(`[INFO] ${args.join(" ")}\n`),
|
|
12
12
|
warn: (...args) => process.stderr.write(`[WARN] ${args.join(" ")}\n`),
|
|
13
13
|
error: (...args) => process.stderr.write(`[ERROR] ${args.join(" ")}\n`),
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
// ── 会话缓存(跨 session 共享,username → CQ cookie) ─────────────
|
|
16
|
+
const cookieCache = new Map();
|
|
16
17
|
// ── 认证 ──────────────────────────────────────────────────────────
|
|
17
18
|
/** 用 RSA 公钥加密密码(与前端 JSEncrypt 行为一致) */
|
|
18
19
|
function encryptPassword(publicKeyB64, password) {
|
|
@@ -22,114 +23,163 @@ function encryptPassword(publicKeyB64, password) {
|
|
|
22
23
|
const encrypted = publicKey.encrypt(password, "RSAES-PKCS1-V1_5");
|
|
23
24
|
return forge.util.encode64(encrypted);
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
26
|
+
// ── 每个 session 独立的上下文 ─────────────────────────────────────
|
|
27
|
+
function createSessionContext() {
|
|
28
|
+
let config = {
|
|
29
|
+
baseUrl: process.env.CQ_BASE_URL ?? "",
|
|
30
|
+
username: process.env.CQ_USERNAME ?? "",
|
|
31
|
+
password: process.env.CQ_PASSWORD ?? "",
|
|
32
|
+
};
|
|
33
|
+
// 优先从全局缓存恢复 cookie,避免每个新 session 都重新登录
|
|
34
|
+
let sessionCookie = cookieCache.get(config.username) ?? null;
|
|
35
|
+
async function login() {
|
|
36
|
+
if (!config.baseUrl)
|
|
37
|
+
throw new Error("未配置 baseUrl,请先调用 connect 工具或设置 CQ_BASE_URL 环境变量");
|
|
38
|
+
if (!config.username)
|
|
39
|
+
throw new Error("未配置 username,请先调用 connect 工具或设置 CQ_USERNAME 环境变量");
|
|
40
|
+
if (!config.password)
|
|
41
|
+
throw new Error("未配置 password,请先调用 connect 工具或设置 CQ_PASSWORD 环境变量");
|
|
42
|
+
log.info(`登录 ${config.baseUrl},用户: ${config.username}`);
|
|
43
|
+
const keyResp = await fetch(`${config.baseUrl}/user/sys/transmission/publicKey`);
|
|
44
|
+
if (!keyResp.ok)
|
|
45
|
+
throw new Error(`获取公钥失败: HTTP ${keyResp.status}`);
|
|
46
|
+
const keyData = (await keyResp.json());
|
|
47
|
+
if (keyData.resCode !== 10000)
|
|
48
|
+
throw new Error(`获取公钥异常: ${keyData.resCode}`);
|
|
49
|
+
const encryptedPassword = encryptPassword(keyData.data.publicKey, config.password);
|
|
50
|
+
const resp = await fetch(`${config.baseUrl}/user/login`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ userId: config.username, password: encryptedPassword }),
|
|
54
|
+
});
|
|
55
|
+
if (!resp.ok)
|
|
56
|
+
throw new Error(`登录请求失败: HTTP ${resp.status}`);
|
|
57
|
+
const data = (await resp.json());
|
|
58
|
+
if (data.resCode !== 10000)
|
|
59
|
+
throw new Error(`登录失败: ${data.resMsg}`);
|
|
60
|
+
const sid = data.data?.sessionId;
|
|
61
|
+
if (sid) {
|
|
62
|
+
sessionCookie = Buffer.from(sid).toString("base64");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const setCookie = resp.headers.get("set-cookie") ?? "";
|
|
66
|
+
const match = setCookie.match(/SESSION=([^;]+)/);
|
|
67
|
+
if (match) {
|
|
68
|
+
sessionCookie = match[1];
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
throw new Error("登录成功但未获取到 SESSION");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 写入全局缓存,同账号的后续 session 可直接复用
|
|
75
|
+
cookieCache.set(config.username, sessionCookie);
|
|
76
|
+
log.info(`登录成功,cookie 已缓存 (用户: ${config.username})`);
|
|
50
77
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
async function post(path, body) {
|
|
79
|
+
if (!sessionCookie)
|
|
80
|
+
await login();
|
|
81
|
+
const resp = await fetch(`${config.baseUrl}${path}`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
Cookie: `SESSION=${sessionCookie}`,
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
});
|
|
89
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
90
|
+
log.warn(`Session 已过期,清除缓存并重新登录 (用户: ${config.username})...`);
|
|
91
|
+
cookieCache.delete(config.username);
|
|
92
|
+
sessionCookie = null;
|
|
93
|
+
await login();
|
|
94
|
+
return post(path, body);
|
|
95
|
+
}
|
|
96
|
+
if (!resp.ok)
|
|
97
|
+
throw new Error(`HTTP ${resp.status} ${path}`);
|
|
98
|
+
const result = (await resp.json());
|
|
99
|
+
if (result.resCode !== 10000)
|
|
100
|
+
throw new Error(`[${result.resCode}] ${result.resMsg}`);
|
|
101
|
+
return result.data;
|
|
56
102
|
}
|
|
57
|
-
|
|
58
|
-
|
|
103
|
+
async function metaNode(payload) {
|
|
104
|
+
const data = await post("/dms/meta/node", {
|
|
105
|
+
...payload,
|
|
106
|
+
globalHavePermissionFlag: false,
|
|
107
|
+
});
|
|
108
|
+
return data?.data ?? [];
|
|
59
109
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Cookie: `SESSION=${sessionCookie}`,
|
|
110
|
+
return {
|
|
111
|
+
config: () => config,
|
|
112
|
+
setConfig: (c) => {
|
|
113
|
+
config = c;
|
|
114
|
+
// 切换账号时尝试从缓存恢复,避免重复登录
|
|
115
|
+
sessionCookie = cookieCache.get(c.username) ?? null;
|
|
116
|
+
log.info(sessionCookie
|
|
117
|
+
? `connect: 从缓存恢复 cookie (用户: ${c.username})`
|
|
118
|
+
: `connect: 无缓存,首次访问时将执行登录 (用户: ${c.username})`);
|
|
70
119
|
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
log.warn("Session 已过期,重新登录...");
|
|
75
|
-
sessionCookie = null;
|
|
76
|
-
await login();
|
|
77
|
-
return post(path, body);
|
|
78
|
-
}
|
|
79
|
-
if (!resp.ok)
|
|
80
|
-
throw new Error(`HTTP ${resp.status} ${path}`);
|
|
81
|
-
const result = (await resp.json());
|
|
82
|
-
if (result.resCode !== 10000)
|
|
83
|
-
throw new Error(`[${result.resCode}] ${result.resMsg}`);
|
|
84
|
-
return result.data;
|
|
120
|
+
post,
|
|
121
|
+
metaNode,
|
|
122
|
+
};
|
|
85
123
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
124
|
+
// ── 工具注册工厂(每个 session 独立) ─────────────────────────────
|
|
125
|
+
function createMcpServer() {
|
|
126
|
+
const server = new McpServer({ name: "cq-mcp-server", version: "0.2.0" });
|
|
127
|
+
const ctx = createSessionContext();
|
|
128
|
+
// ── 工具零:连接配置 ────────────────────────────────────────────
|
|
129
|
+
server.tool("connect", `配置 CloudQuery 平台的连接信息(地址、账号、密码)。
|
|
130
|
+
|
|
131
|
+
用途:在调用其他工具前,先通过此工具设置连接参数。适用于未配置环境变量的场景(如 Dify、动态多租户等)。
|
|
132
|
+
参数说明:
|
|
133
|
+
- base_url:CloudQuery 平台地址,如 http://10.10.2.73
|
|
134
|
+
- username:登录账号
|
|
135
|
+
- password:登录密码(明文,服务端自动加密)
|
|
136
|
+
返回:连接配置成功的确认信息。
|
|
137
|
+
注意:每次调用会重置连接配置并清除已有 Session,强制重新登录。`, {
|
|
138
|
+
base_url: z.string().describe("CloudQuery 平台地址,如 http://10.10.2.73"),
|
|
139
|
+
username: z.string().describe("登录账号"),
|
|
140
|
+
password: z.string().describe("登录密码"),
|
|
141
|
+
}, async ({ base_url, username, password }) => {
|
|
142
|
+
ctx.setConfig({ baseUrl: base_url.replace(/\/$/, ""), username, password });
|
|
143
|
+
log.info(`connect: 配置已更新 baseUrl=${base_url} user=${username}`);
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: `连接配置已设置:${base_url},用户:${username}` }],
|
|
146
|
+
};
|
|
90
147
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// ── MCP Server ────────────────────────────────────────────────────
|
|
94
|
-
const server = new McpServer({
|
|
95
|
-
name: "cq-mcp-server",
|
|
96
|
-
version: "0.1.0",
|
|
97
|
-
});
|
|
98
|
-
// ── 工具一:查询数据库清单 ─────────────────────────────────────────
|
|
99
|
-
server.tool("list_databases", `查询所有数据库连接及其下的数据库实例列表。
|
|
148
|
+
// ── 工具一:查询数据库清单 ───────────────────────────────────────
|
|
149
|
+
server.tool("list_databases", `查询所有数据库连接及其下的数据库实例列表。
|
|
100
150
|
|
|
101
151
|
用途:了解当前 CloudQuery 平台上有哪些数据库连接,以及每个连接下有哪些数据库。
|
|
102
152
|
返回:连接名称、连接ID、数据库类型,以及每个连接下的数据库列表。
|
|
103
153
|
后续:拿到 connection_id 和 connection_type 后,可调用 list_tables 查看具体库的表。`, {}, async () => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
154
|
+
log.info("list_databases: 查询连接列表");
|
|
155
|
+
const connections = await ctx.metaNode({ nodeType: "root", nodePath: "/root" });
|
|
156
|
+
if (!connections.length)
|
|
157
|
+
return { content: [{ type: "text", text: "未找到任何数据库连接" }] };
|
|
158
|
+
const lines = [];
|
|
159
|
+
for (const conn of connections) {
|
|
160
|
+
const { connectionId, connectionType, nodeName } = conn;
|
|
161
|
+
lines.push(`## 连接: ${nodeName} (connection_id=${connectionId}, connection_type=${connectionType})`);
|
|
162
|
+
const databases = await ctx.metaNode({
|
|
163
|
+
nodeType: "connection",
|
|
164
|
+
nodePath: `/root/${connectionId}`,
|
|
165
|
+
nodePathWithType: `/CONNECTION:${connectionId}`,
|
|
166
|
+
connectionId,
|
|
167
|
+
connectionType,
|
|
168
|
+
});
|
|
169
|
+
log.info(` └─ ${nodeName}: ${databases.length} 个数据库`);
|
|
170
|
+
if (databases.length) {
|
|
171
|
+
for (const db of databases)
|
|
172
|
+
lines.push(` - ${db.nodeName}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
lines.push(" (无数据库)");
|
|
176
|
+
}
|
|
177
|
+
lines.push("");
|
|
123
178
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
130
|
-
});
|
|
131
|
-
// ── 工具二:查询表清单 ────────────────────────────────────────────
|
|
132
|
-
server.tool("list_tables", `查询指定数据库 Schema 下的所有普通表。
|
|
179
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
180
|
+
});
|
|
181
|
+
// ── 工具二:查询表清单 ──────────────────────────────────────────
|
|
182
|
+
server.tool("list_tables", `查询指定数据库 Schema 下的所有普通表。
|
|
133
183
|
|
|
134
184
|
用途:了解某个数据库 Schema 下有哪些表,为后续字段查询或 SQL 编写做准备。
|
|
135
185
|
参数说明:
|
|
@@ -138,30 +188,30 @@ server.tool("list_tables", `查询指定数据库 Schema 下的所有普通表
|
|
|
138
188
|
- schema:Schema 名称,PostgreSQL 常用 public 或与库同名的 schema(如 pam)
|
|
139
189
|
返回:表名列表及总数。
|
|
140
190
|
后续:用 get_table_columns 查看具体表的字段结构。`, {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}, async ({ connection_id, connection_type, database, schema }) => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
191
|
+
connection_id: z.number().describe("数据库连接ID,从 list_databases 获取"),
|
|
192
|
+
connection_type: z.string().describe("数据库类型,如 PostgreSQL、MySQL,从 list_databases 获取"),
|
|
193
|
+
database: z.string().describe("数据库名称,如 pam、postgres"),
|
|
194
|
+
schema: z.string().default("public").describe("Schema 名称,PostgreSQL 默认 public"),
|
|
195
|
+
}, async ({ connection_id, connection_type, database, schema }) => {
|
|
196
|
+
log.info(`list_tables: ${database}.${schema} (conn=${connection_id})`);
|
|
197
|
+
const tables = await ctx.metaNode({
|
|
198
|
+
nodeType: "tableGroup",
|
|
199
|
+
nodePath: `/root/${connection_id}/${database}/${schema}/tables`,
|
|
200
|
+
nodePathWithType: `/CONNECTION:${connection_id}/DATABASE:${database}/SCHEMA:${schema}`,
|
|
201
|
+
connectionId: connection_id,
|
|
202
|
+
connectionType: connection_type,
|
|
203
|
+
});
|
|
204
|
+
if (!tables.length) {
|
|
205
|
+
return { content: [{ type: "text", text: `${database}.${schema} 下未找到任何表` }] };
|
|
206
|
+
}
|
|
207
|
+
log.info(` └─ 共 ${tables.length} 张表`);
|
|
208
|
+
const lines = [`${database}.${schema} 共 ${tables.length} 张表:`, ""];
|
|
209
|
+
for (const t of tables)
|
|
210
|
+
lines.push(`- ${t.nodeName}`);
|
|
211
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
153
212
|
});
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
log.info(` └─ 共 ${tables.length} 张表`);
|
|
158
|
-
const lines = [`${database}.${schema} 共 ${tables.length} 张表:`, ""];
|
|
159
|
-
for (const t of tables)
|
|
160
|
-
lines.push(`- ${t.nodeName}`);
|
|
161
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
162
|
-
});
|
|
163
|
-
// ── 工具三:查询表字段 ────────────────────────────────────────────
|
|
164
|
-
server.tool("get_table_columns", `查询指定表的完整字段定义,包括字段名、数据类型、长度、是否可空、业务注释。
|
|
213
|
+
// ── 工具三:查询表字段 ──────────────────────────────────────────
|
|
214
|
+
server.tool("get_table_columns", `查询指定表的完整字段定义,包括字段名、数据类型、长度、是否可空、业务注释。
|
|
165
215
|
|
|
166
216
|
用途:了解表结构,辅助编写精确的 SQL 查询语句。
|
|
167
217
|
参数说明:
|
|
@@ -169,38 +219,38 @@ server.tool("get_table_columns", `查询指定表的完整字段定义,包括
|
|
|
169
219
|
- database / schema:从 list_databases 获取
|
|
170
220
|
- table:从 list_tables 获取
|
|
171
221
|
返回:字段名、数据类型(如 int8/varchar/timestamp)、长度、是否可空(是/否)、字段注释。`, {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}, async ({ connection_id, connection_type, database, schema, table }) => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
222
|
+
connection_id: z.number().describe("数据库连接ID,从 list_databases 获取"),
|
|
223
|
+
connection_type: z.string().describe("数据库类型,如 PostgreSQL、MySQL"),
|
|
224
|
+
database: z.string().describe("数据库名称"),
|
|
225
|
+
schema: z.string().describe("Schema 名称"),
|
|
226
|
+
table: z.string().describe("表名,从 list_tables 获取"),
|
|
227
|
+
}, async ({ connection_id, connection_type, database, schema, table }) => {
|
|
228
|
+
log.info(`get_table_columns: ${database}.${schema}.${table} (conn=${connection_id})`);
|
|
229
|
+
const columns = await ctx.metaNode({
|
|
230
|
+
nodeType: "columnGroup",
|
|
231
|
+
nodePath: `/root/${connection_id}/${database}/${schema}/tables/${table}/columns`,
|
|
232
|
+
nodePathWithType: `/CONNECTION:${connection_id}/DATABASE:${database}/SCHEMA:${schema}/TABLE:${table}`,
|
|
233
|
+
connectionId: connection_id,
|
|
234
|
+
connectionType: connection_type,
|
|
235
|
+
});
|
|
236
|
+
if (!columns.length) {
|
|
237
|
+
return { content: [{ type: "text", text: `表 ${database}.${schema}.${table} 未找到字段信息` }] };
|
|
238
|
+
}
|
|
239
|
+
log.info(` └─ ${columns.length} 个字段`);
|
|
240
|
+
const lines = [
|
|
241
|
+
`表 ${database}.${schema}.${table} 共 ${columns.length} 个字段:`,
|
|
242
|
+
"",
|
|
243
|
+
`${"字段名".padEnd(25)} ${"类型".padEnd(15)} ${"长度".padEnd(8)} ${"可空".padEnd(6)} 注释`,
|
|
244
|
+
"-".repeat(75),
|
|
245
|
+
];
|
|
246
|
+
for (const col of columns) {
|
|
247
|
+
const opts = col.nodeOptions ?? {};
|
|
248
|
+
lines.push(`${col.nodeName.padEnd(25)} ${(opts.dataType ?? "").padEnd(15)} ${(opts.dataLength ?? "").padEnd(8)} ${(opts.isNullable ? "是" : "否").padEnd(6)} ${opts.comments ?? ""}`);
|
|
249
|
+
}
|
|
250
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
185
251
|
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
log.info(` └─ ${columns.length} 个字段`);
|
|
190
|
-
const lines = [
|
|
191
|
-
`表 ${database}.${schema}.${table} 共 ${columns.length} 个字段:`,
|
|
192
|
-
"",
|
|
193
|
-
`${"字段名".padEnd(25)} ${"类型".padEnd(15)} ${"长度".padEnd(8)} ${"可空".padEnd(6)} 注释`,
|
|
194
|
-
"-".repeat(75),
|
|
195
|
-
];
|
|
196
|
-
for (const col of columns) {
|
|
197
|
-
const opts = col.nodeOptions ?? {};
|
|
198
|
-
lines.push(`${col.nodeName.padEnd(25)} ${(opts.dataType ?? "").padEnd(15)} ${(opts.dataLength ?? "").padEnd(8)} ${(opts.isNullable ? "是" : "否").padEnd(6)} ${opts.comments ?? ""}`);
|
|
199
|
-
}
|
|
200
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
201
|
-
});
|
|
202
|
-
// ── 工具四:执行 SQL ──────────────────────────────────────────────
|
|
203
|
-
server.tool("execute_sql", `在指定数据库上执行 SQL 语句,返回查询结果或执行状态。
|
|
252
|
+
// ── 工具四:执行 SQL ────────────────────────────────────────────
|
|
253
|
+
server.tool("execute_sql", `在指定数据库上执行 SQL 语句,返回查询结果或执行状态。
|
|
204
254
|
|
|
205
255
|
用途:直接运行 SQL 查询数据,或执行 DML(INSERT/UPDATE/DELETE)操作。
|
|
206
256
|
参数说明:
|
|
@@ -213,67 +263,117 @@ server.tool("execute_sql", `在指定数据库上执行 SQL 语句,返回查
|
|
|
213
263
|
- DML:影响行数和执行耗时
|
|
214
264
|
- 错误:具体错误信息
|
|
215
265
|
注意:一次只执行一条 SQL 语句。`, {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}, async ({ connection_id, connection_type, database, schema, sql }) => {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
266
|
+
connection_id: z.number().describe("数据库连接ID,从 list_databases 获取"),
|
|
267
|
+
connection_type: z.string().default("PostgreSQL").describe("数据库类型,如 PostgreSQL、MySQL"),
|
|
268
|
+
database: z.string().describe("数据库名称"),
|
|
269
|
+
schema: z.string().default("public").describe("执行 SQL 的默认 Schema,默认 public"),
|
|
270
|
+
sql: z.string().describe("SQL 语句,一次执行一条"),
|
|
271
|
+
}, async ({ connection_id, connection_type, database, schema, sql }) => {
|
|
272
|
+
const tabKey = `mcp-${Date.now()}`;
|
|
273
|
+
const preview = sql.length > 60 ? sql.slice(0, 60) + "..." : sql;
|
|
274
|
+
log.info(`execute_sql: [${database}.${schema}] ${preview}`);
|
|
275
|
+
const data = await ctx.post("/dms/segment/statement/blocking/execute", {
|
|
276
|
+
connectionId: connection_id,
|
|
277
|
+
dataSourceType: connection_type,
|
|
278
|
+
databaseName: database,
|
|
279
|
+
operatingObject: schema,
|
|
280
|
+
statements: [sql],
|
|
281
|
+
offset: 0,
|
|
282
|
+
rowCount: 500,
|
|
283
|
+
tabKey,
|
|
284
|
+
plSql: false,
|
|
285
|
+
sortModels: null,
|
|
286
|
+
filterModel: null,
|
|
287
|
+
autoCommit: false,
|
|
288
|
+
actionType: null,
|
|
289
|
+
});
|
|
290
|
+
const infos = data?.executionInfos ?? [];
|
|
291
|
+
const lines = [];
|
|
292
|
+
for (const info of infos) {
|
|
293
|
+
const log_msg = info.executeLogInfo?.message;
|
|
294
|
+
const resp = info.response;
|
|
295
|
+
if (!resp?.success) {
|
|
296
|
+
const errMsg = resp?.executeError?.message ?? log_msg?.error ?? "执行失败";
|
|
297
|
+
log.error(`SQL 执行失败: ${errMsg}`);
|
|
298
|
+
lines.push(`错误: ${errMsg}`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!resp.resultData?.length) {
|
|
302
|
+
const affected = log_msg?.affectedRows ?? 0;
|
|
303
|
+
const ms = log_msg?.duration ?? 0;
|
|
304
|
+
log.info(` └─ 执行成功,影响 ${affected} 行,${ms}ms`);
|
|
305
|
+
lines.push(`执行成功\n影响行数: ${affected}\n耗时: ${ms}ms`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const resultData = resp.resultData;
|
|
309
|
+
const colInfos = resp.columnInfos ?? [];
|
|
310
|
+
const colNames = colInfos.length
|
|
311
|
+
? colInfos.map((c) => c.columnName)
|
|
312
|
+
: Object.keys(resultData[0]);
|
|
313
|
+
const ms = log_msg?.duration ?? 0;
|
|
314
|
+
log.info(` └─ 返回 ${resultData.length} 行,${ms}ms`);
|
|
315
|
+
lines.push(colNames.join(" | "));
|
|
316
|
+
lines.push("-".repeat(Math.max(colNames.join(" | ").length, 20)));
|
|
317
|
+
for (const row of resultData) {
|
|
318
|
+
lines.push(colNames.map((col) => String(row[col]?.value ?? "")).join(" | "));
|
|
319
|
+
}
|
|
320
|
+
lines.push(`\n共 ${resultData.length} 行 耗时 ${ms}ms`);
|
|
321
|
+
}
|
|
322
|
+
return { content: [{ type: "text", text: lines.join("\n") || "无输出" }] };
|
|
239
323
|
});
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
324
|
+
return server;
|
|
325
|
+
}
|
|
326
|
+
// ── 启动 ──────────────────────────────────────────────────────────
|
|
327
|
+
const MCP_TRANSPORT = process.env.MCP_TRANSPORT ?? "stdio";
|
|
328
|
+
const MCP_PORT = parseInt(process.env.MCP_PORT ?? "8080", 10);
|
|
329
|
+
log.info(`CloudQuery MCP Server 启动 transport=${MCP_TRANSPORT}`);
|
|
330
|
+
if (MCP_TRANSPORT === "http") {
|
|
331
|
+
// session_id → transport 映射,支持多用户并发
|
|
332
|
+
const sessions = new Map();
|
|
333
|
+
const httpServer = createServer(async (req, res) => {
|
|
334
|
+
if (!req.url?.startsWith("/mcp")) {
|
|
335
|
+
res.writeHead(404).end("Not Found");
|
|
336
|
+
return;
|
|
250
337
|
}
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
338
|
+
// 每个新 session(首次 initialize 请求)创建独立的 server + transport
|
|
339
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
340
|
+
if (!sessionId) {
|
|
341
|
+
// 新 session:为该用户创建独立实例
|
|
342
|
+
const transport = new StreamableHTTPServerTransport({
|
|
343
|
+
sessionIdGenerator: () => randomUUID(),
|
|
344
|
+
});
|
|
345
|
+
const server = createMcpServer();
|
|
346
|
+
await server.connect(transport);
|
|
347
|
+
transport.onclose = () => {
|
|
348
|
+
const sid = transport.sessionId;
|
|
349
|
+
if (sid) {
|
|
350
|
+
sessions.delete(sid);
|
|
351
|
+
log.info(`Session 关闭: ${sid},当前活跃: ${sessions.size}`);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
await transport.handleRequest(req, res);
|
|
355
|
+
// handleRequest 后 sessionId 已生成,存入 map
|
|
356
|
+
if (transport.sessionId) {
|
|
357
|
+
sessions.set(transport.sessionId, transport);
|
|
358
|
+
log.info(`新 Session: ${transport.sessionId},当前活跃: ${sessions.size}`);
|
|
359
|
+
}
|
|
258
360
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
lines.push(colNames.join(" | "));
|
|
268
|
-
lines.push("-".repeat(Math.max(colNames.join(" | ").length, 20)));
|
|
269
|
-
for (const row of resultData) {
|
|
270
|
-
lines.push(colNames.map((col) => String(row[col]?.value ?? "")).join(" | "));
|
|
361
|
+
else {
|
|
362
|
+
// 已有 session:路由到对应 transport
|
|
363
|
+
const transport = sessions.get(sessionId);
|
|
364
|
+
if (!transport) {
|
|
365
|
+
res.writeHead(404).end("Session not found");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
await transport.handleRequest(req, res);
|
|
271
369
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
370
|
+
});
|
|
371
|
+
httpServer.listen(MCP_PORT, () => {
|
|
372
|
+
log.info(`HTTP MCP Server 已启动,监听端口 ${MCP_PORT},路径 /mcp`);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const server = createMcpServer();
|
|
377
|
+
const transport = new StdioServerTransport();
|
|
378
|
+
await server.connect(transport);
|
|
379
|
+
}
|
package/package.json
CHANGED