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.
Files changed (3) hide show
  1. package/README.md +52 -3
  2. package/dist/index.js +315 -215
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,19 +1,68 @@
1
1
  # CloudQuery MCP Server 使用指南
2
2
 
3
- 本 MCP Server 连接 CloudQuery 平台,提供四个工具,让 AI 能够探索数据库结构并执行 SQL 查询。
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
- **必须按顺序调用**:后续工具所需的 `connection_id`、`connection_type`、`database`、`schema` 参数,都只能从前面的工具返回结果中获取,不能凭空猜测。
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
- let sessionCookie = null;
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
- async function login() {
26
- log.info(`登录 ${BASE_URL},用户: ${USERNAME}`);
27
- const keyResp = await fetch(`${BASE_URL}/user/sys/transmission/publicKey`);
28
- if (!keyResp.ok)
29
- throw new Error(`获取公钥失败: HTTP ${keyResp.status}`);
30
- const keyData = (await keyResp.json());
31
- if (keyData.resCode !== 10000)
32
- throw new Error(`获取公钥异常: ${keyData.resCode}`);
33
- const encryptedPassword = encryptPassword(keyData.data.publicKey, PASSWORD);
34
- const resp = await fetch(`${BASE_URL}/user/login`, {
35
- method: "POST",
36
- headers: { "Content-Type": "application/json" },
37
- body: JSON.stringify({ userId: USERNAME, password: encryptedPassword }),
38
- });
39
- if (!resp.ok)
40
- throw new Error(`登录请求失败: HTTP ${resp.status}`);
41
- const data = (await resp.json());
42
- if (data.resCode !== 10000)
43
- throw new Error(`登录失败: ${data.resMsg}`);
44
- // SESSION cookie = base64(sessionId)
45
- const sid = data.data?.sessionId;
46
- if (sid) {
47
- sessionCookie = Buffer.from(sid).toString("base64");
48
- log.info("登录成功");
49
- return;
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
- const setCookie = resp.headers.get("set-cookie") ?? "";
52
- const match = setCookie.match(/SESSION=([^;]+)/);
53
- if (match) {
54
- sessionCookie = match[1];
55
- log.info("登录成功(cookie)");
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
- else {
58
- throw new Error("登录成功但未获取到 SESSION");
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
- // ── HTTP 封装 ─────────────────────────────────────────────────────
62
- async function post(path, body) {
63
- if (!sessionCookie)
64
- await login();
65
- const resp = await fetch(`${BASE_URL}${path}`, {
66
- method: "POST",
67
- headers: {
68
- "Content-Type": "application/json",
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
- body: JSON.stringify(body),
72
- });
73
- if (resp.status === 401 || resp.status === 403) {
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
- async function metaNode(payload) {
87
- const data = await post("/dms/meta/node", {
88
- ...payload,
89
- globalHavePermissionFlag: false,
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
- return data?.data ?? [];
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
- log.info("list_databases: 查询连接列表");
105
- const connections = await metaNode({ nodeType: "root", nodePath: "/root" });
106
- if (!connections.length)
107
- return { content: [{ type: "text", text: "未找到任何数据库连接" }] };
108
- const lines = [];
109
- for (const conn of connections) {
110
- const { connectionId, connectionType, nodeName } = conn;
111
- lines.push(`## 连接: ${nodeName} (connection_id=${connectionId}, connection_type=${connectionType})`);
112
- const databases = await metaNode({
113
- nodeType: "connection",
114
- nodePath: `/root/${connectionId}`,
115
- nodePathWithType: `/CONNECTION:${connectionId}`,
116
- connectionId,
117
- connectionType,
118
- });
119
- log.info(` └─ ${nodeName}: ${databases.length} 个数据库`);
120
- if (databases.length) {
121
- for (const db of databases)
122
- lines.push(` - ${db.nodeName}`);
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
- else {
125
- lines.push(" (无数据库)");
126
- }
127
- lines.push("");
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
- connection_id: z.number().describe("数据库连接ID,从 list_databases 获取"),
142
- connection_type: z.string().describe("数据库类型,如 PostgreSQL、MySQL,从 list_databases 获取"),
143
- database: z.string().describe("数据库名称,如 pam、postgres"),
144
- schema: z.string().default("public").describe("Schema 名称,PostgreSQL 默认 public"),
145
- }, async ({ connection_id, connection_type, database, schema }) => {
146
- log.info(`list_tables: ${database}.${schema} (conn=${connection_id})`);
147
- const tables = await metaNode({
148
- nodeType: "tableGroup",
149
- nodePath: `/root/${connection_id}/${database}/${schema}/tables`,
150
- nodePathWithType: `/CONNECTION:${connection_id}/DATABASE:${database}/SCHEMA:${schema}`,
151
- connectionId: connection_id,
152
- connectionType: connection_type,
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
- if (!tables.length) {
155
- return { content: [{ type: "text", text: `${database}.${schema} 下未找到任何表` }] };
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
- connection_id: z.number().describe("数据库连接ID,从 list_databases 获取"),
173
- connection_type: z.string().describe("数据库类型,如 PostgreSQL、MySQL"),
174
- database: z.string().describe("数据库名称"),
175
- schema: z.string().describe("Schema 名称"),
176
- table: z.string().describe("表名,从 list_tables 获取"),
177
- }, async ({ connection_id, connection_type, database, schema, table }) => {
178
- log.info(`get_table_columns: ${database}.${schema}.${table} (conn=${connection_id})`);
179
- const columns = await metaNode({
180
- nodeType: "columnGroup",
181
- nodePath: `/root/${connection_id}/${database}/${schema}/tables/${table}/columns`,
182
- nodePathWithType: `/CONNECTION:${connection_id}/DATABASE:${database}/SCHEMA:${schema}/TABLE:${table}`,
183
- connectionId: connection_id,
184
- connectionType: connection_type,
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
- if (!columns.length) {
187
- return { content: [{ type: "text", text: `表 ${database}.${schema}.${table} 未找到字段信息` }] };
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
- connection_id: z.number().describe("数据库连接ID,从 list_databases 获取"),
217
- connection_type: z.string().default("PostgreSQL").describe("数据库类型,如 PostgreSQL、MySQL"),
218
- database: z.string().describe("数据库名称"),
219
- schema: z.string().default("public").describe("执行 SQL 的默认 Schema,默认 public"),
220
- sql: z.string().describe("SQL 语句,一次执行一条"),
221
- }, async ({ connection_id, connection_type, database, schema, sql }) => {
222
- const tabKey = `mcp-${Date.now()}`;
223
- const preview = sql.length > 60 ? sql.slice(0, 60) + "..." : sql;
224
- log.info(`execute_sql: [${database}.${schema}] ${preview}`);
225
- const data = await post("/dms/segment/statement/blocking/execute", {
226
- connectionId: connection_id,
227
- dataSourceType: connection_type,
228
- databaseName: database,
229
- operatingObject: schema,
230
- statements: [sql],
231
- offset: 0,
232
- rowCount: 500,
233
- tabKey,
234
- plSql: false,
235
- sortModels: null,
236
- filterModel: null,
237
- autoCommit: false,
238
- actionType: null,
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
- const infos = data?.executionInfos ?? [];
241
- const lines = [];
242
- for (const info of infos) {
243
- const log_msg = info.executeLogInfo?.message;
244
- const resp = info.response;
245
- if (!resp?.success) {
246
- const errMsg = resp?.executeError?.message ?? log_msg?.error ?? "执行失败";
247
- log.error(`SQL 执行失败: ${errMsg}`);
248
- lines.push(`错误: ${errMsg}`);
249
- continue;
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
- // DML(无结果集)
252
- if (!resp.resultData?.length) {
253
- const affected = log_msg?.affectedRows ?? 0;
254
- const ms = log_msg?.duration ?? 0;
255
- log.info(` └─ 执行成功,影响 ${affected} 行,${ms}ms`);
256
- lines.push(`执行成功\n影响行数: ${affected}\n耗时: ${ms}ms`);
257
- continue;
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
- // SELECT
260
- const resultData = resp.resultData;
261
- const colInfos = resp.columnInfos ?? [];
262
- const colNames = colInfos.length
263
- ? colInfos.map((c) => c.columnName)
264
- : Object.keys(resultData[0]);
265
- const ms = log_msg?.duration ?? 0;
266
- log.info(` └─ 返回 ${resultData.length} 行,${ms}ms`);
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
- lines.push(`\n共 ${resultData.length} 行 耗时 ${ms}ms`);
273
- }
274
- return { content: [{ type: "text", text: lines.join("\n") || "无输出" }] };
275
- });
276
- // ── 启动 ──────────────────────────────────────────────────────────
277
- log.info(`CloudQuery MCP Server 启动 BASE_URL=${BASE_URL} USER=${USERNAME}`);
278
- const transport = new StdioServerTransport();
279
- await server.connect(transport);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cq-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP Server for CloudQuery platform — list databases, list tables, inspect columns, and execute SQL via MCP tools.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",