@wsh19991219/mcp-server 0.1.3 → 0.1.6
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 +44 -24
- package/SKILL.md +3 -3
- package/bin/data-mgr-login.js +21 -0
- package/bin/data-mgr-mcp.js +1 -1
- package/lib/config.js +42 -9
- package/lib/login-page.html +101 -0
- package/lib/login-server.js +42 -108
- package/lib/tools.js +45 -13
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Data Manager MCP Server — 通过 [MCP](https://modelcontextprotocol.io) 协议
|
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- **鉴权管理**:浏览器登录(页面填写 API 地址 + 账号密码),JWT 自动保存
|
|
8
8
|
- **表结构探索**:模糊搜表、查看列定义
|
|
9
9
|
- **数据查询**:预定义指标 + 自定义只读 SQL
|
|
10
10
|
- **多库支持**:主库及多个区域数据库 group
|
|
@@ -13,7 +13,7 @@ Data Manager MCP Server — 通过 [MCP](https://modelcontextprotocol.io) 协议
|
|
|
13
13
|
## 安装
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm install -g @
|
|
16
|
+
npm install -g @wang19991219/mcp-server
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
需要 **Node.js 18+**。
|
|
@@ -53,29 +53,47 @@ npm install -g @data_mgr/mcp-server
|
|
|
53
53
|
|
|
54
54
|
## MCP 工具
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
56
|
+
|
|
57
|
+
| 工具 | 功能 | 说明 |
|
|
58
|
+
| ----------------- | ---------- | -------------------------------- |
|
|
59
|
+
| `check_auth` | 检查登录状态 | 返回用户名、token 有效期 |
|
|
60
|
+
| `login` | 浏览器登录 | 在页面填写 **API 地址**、账号、密码(无硬编码默认地址) |
|
|
61
|
+
| `analytics_query` | 预定义指标查询 | 支持 metric、filters、limit 等参数 |
|
|
62
|
+
| `api_request` | 通用 REST 请求 | GET/POST/PUT/DELETE,含自定义 SQL |
|
|
63
|
+
|
|
62
64
|
|
|
63
65
|
### api_request 端点
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
| `/
|
|
69
|
-
| `/api/v1/analytics/
|
|
70
|
-
| `/api/v1/analytics/schema?
|
|
71
|
-
| `/api/v1/analytics/
|
|
67
|
+
|
|
68
|
+
| 端点 | 方法 | 用途 |
|
|
69
|
+
| -------------------------------------- | ---- | ------------------ |
|
|
70
|
+
| `/health` | GET | 健康检查,返回可用数据库 group |
|
|
71
|
+
| `/api/v1/analytics/catalog` | GET | 列出可用指标和数据库 group |
|
|
72
|
+
| `/api/v1/analytics/schema?q=<关键词>` | GET | 模糊搜索表名 |
|
|
73
|
+
| `/api/v1/analytics/schema?tables=<表名>` | GET | 查看表的列结构 |
|
|
74
|
+
| `/api/v1/analytics/sql` | POST | 执行自定义只读 SQL |
|
|
75
|
+
|
|
72
76
|
|
|
73
77
|
## 环境变量
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
| `
|
|
79
|
+
|
|
80
|
+
| 变量 | 说明 | 默认值 |
|
|
81
|
+
| --------------------- | ------------------------ | ------------- |
|
|
82
|
+
| `DATA_MGR_API_URL` | 后端 API 地址(可选;未设置时在登录页填写) | — |
|
|
83
|
+
| `DATA_MGR_CONFIG_DIR` | 配置文件目录 | `~/.data-mgr` |
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
## 登录说明
|
|
87
|
+
|
|
88
|
+
1. 调用 MCP 工具 `login`(或对话中说「登录 data-mgr」)
|
|
89
|
+
2. 浏览器打开本地登录页,填写:
|
|
90
|
+
- **API 地址**:如 `http://your-host:8092`(8092 端口一般为 HTTP)
|
|
91
|
+
- **账号 / 密码**
|
|
92
|
+
3. 成功后写入 `~/.data-mgr/config.json`(含 `apiUrl` 与 `token`)
|
|
93
|
+
|
|
94
|
+
也可预先设置环境变量 `DATA_MGR_API_URL`,登录页会自动填充。
|
|
95
|
+
|
|
96
|
+
**若登录页仍是旧版(无 API 地址框)**:在 Cursor 中 **Settings → MCP → 重启 data-mgr**,并 **Ctrl+F5** 刷新登录页;或执行 `npm install -g ./data-mgr-mcp-server` 更新全局包。
|
|
79
97
|
|
|
80
98
|
## 使用示例
|
|
81
99
|
|
|
@@ -88,11 +106,13 @@ npm install -g @data_mgr/mcp-server
|
|
|
88
106
|
|
|
89
107
|
## 相关包
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
| [
|
|
109
|
+
|
|
110
|
+
| 包名 | 用途 |
|
|
111
|
+
| ---------------------------------------------------------------------- | --------------------------------- |
|
|
112
|
+
| `[@data_mgr/cli](https://www.npmjs.com/package/@data_mgr/cli)` | Data Manager CLI,适用于 Cursor Agent |
|
|
113
|
+
| `[weather-query-cli](https://www.npmjs.com/package/weather-query-cli)` | 天气查询 CLI |
|
|
114
|
+
|
|
95
115
|
|
|
96
116
|
## License
|
|
97
117
|
|
|
98
|
-
MIT
|
|
118
|
+
MIT
|
package/SKILL.md
CHANGED
|
@@ -15,14 +15,14 @@ description: >-
|
|
|
15
15
|
|
|
16
16
|
1. 全局安装:`npm install -g @data_mgr/mcp-server`
|
|
17
17
|
2. 注册 MCP 服务(见下方 [注册](#注册到-claude-code) 章节)
|
|
18
|
-
3. 对话中触发登录:Agent
|
|
18
|
+
3. 对话中触发登录:Agent 调用 `login`,在浏览器页填写 **API 地址**、账号、密码(无内置默认地址)
|
|
19
19
|
|
|
20
20
|
## MCP 工具
|
|
21
21
|
|
|
22
22
|
| 工具 | 功能 | 关键参数 |
|
|
23
23
|
| --- | --- | --- |
|
|
24
24
|
| `check_auth` | 检查登录状态,返回 username / token 有效期 | 无 |
|
|
25
|
-
| `login` |
|
|
25
|
+
| `login` | 浏览器登录(填写 API 地址 + 账号密码) | `openBrowser`: bool(默认 true) |
|
|
26
26
|
| `analytics_query` | 查询预定义指标 | `metric`(必填), `db`, `params`, `filters`, `limit` |
|
|
27
27
|
| `api_request` | 通用 REST 请求(GET/POST/PUT/DELETE) | `method`(必填), `path`(必填), `body`, `query` |
|
|
28
28
|
|
|
@@ -135,7 +135,7 @@ npm install -g @data_mgr/mcp-server
|
|
|
135
135
|
|
|
136
136
|
| 变量 | 说明 | 默认值 |
|
|
137
137
|
| --- | --- | --- |
|
|
138
|
-
| `DATA_MGR_API_URL` | 后端 API
|
|
138
|
+
| `DATA_MGR_API_URL` | 后端 API 地址(可选;未设置时在登录页填写) | — |
|
|
139
139
|
| `DATA_MGR_CONFIG_DIR` | 配置文件目录 | `~/.data-mgr` |
|
|
140
140
|
|
|
141
141
|
## 错误处理
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone login subprocess — always loads latest login-server.js (avoids MCP ESM cache).
|
|
4
|
+
* Usage: node bin/data-mgr-login.js [--no-open]
|
|
5
|
+
* Prints one JSON line to stdout on success.
|
|
6
|
+
*/
|
|
7
|
+
import { runBrowserLogin } from "../lib/login-server.js";
|
|
8
|
+
|
|
9
|
+
const noOpen = process.argv.includes("--no-open");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const result = await runBrowserLogin({
|
|
13
|
+
openBrowser: !noOpen,
|
|
14
|
+
timeoutMs: 5 * 60 * 1000,
|
|
15
|
+
});
|
|
16
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
19
|
+
process.stderr.write(`${message}\n`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
package/bin/data-mgr-mcp.js
CHANGED
package/lib/config.js
CHANGED
|
@@ -8,13 +8,46 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
|
8
8
|
|
|
9
9
|
/** @typedef {{ apiUrl?: string, token?: string, username?: string, userId?: number, tenantId?: number, expiresIn?: string, expiresAt?: string, loggedInAt?: string }} DataMgrConfig */
|
|
10
10
|
|
|
11
|
-
/** Production API (port 8092 serves plain HTTP; use HTTPS only via reverse proxy on 443). */
|
|
12
|
-
export const DEFAULT_API_URL = "http://42.194.226.85:8092";
|
|
13
11
|
export const LOGIN_PATH = "/api/v1/auth/login";
|
|
14
12
|
|
|
15
|
-
/** @param {string} url */
|
|
13
|
+
/** @param {string} [url] */
|
|
16
14
|
export function normalizeApiUrl(url) {
|
|
17
|
-
return String(url).trim().replace(/\/$/, "");
|
|
15
|
+
return String(url ?? "").trim().replace(/\/$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate and normalize API base URL (adds http:// if scheme omitted).
|
|
20
|
+
* @param {string} url
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function parseApiUrl(url) {
|
|
24
|
+
let trimmed = normalizeApiUrl(url);
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
throw new Error("API 地址不能为空");
|
|
27
|
+
}
|
|
28
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
29
|
+
trimmed = `http://${trimmed}`;
|
|
30
|
+
}
|
|
31
|
+
let u;
|
|
32
|
+
try {
|
|
33
|
+
u = new URL(trimmed);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`无效的 API 地址: ${url}`);
|
|
36
|
+
}
|
|
37
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
38
|
+
throw new Error("仅支持 http 或 https");
|
|
39
|
+
}
|
|
40
|
+
const pathSuffix = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
|
|
41
|
+
return u.origin + pathSuffix;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Saved API URL from env or config (no default). */
|
|
45
|
+
export function getSavedApiUrl() {
|
|
46
|
+
const fromEnv = process.env.DATA_MGR_API_URL?.trim();
|
|
47
|
+
if (fromEnv) return normalizeApiUrl(fromEnv);
|
|
48
|
+
const cfg = loadConfig();
|
|
49
|
+
if (cfg.apiUrl) return normalizeApiUrl(cfg.apiUrl);
|
|
50
|
+
return undefined;
|
|
18
51
|
}
|
|
19
52
|
|
|
20
53
|
/** @param {string | number | undefined} expiresIn @param {Date} [from] */
|
|
@@ -73,11 +106,11 @@ export function getConfigPath() {
|
|
|
73
106
|
|
|
74
107
|
/** @returns {string} */
|
|
75
108
|
export function getApiUrl() {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
109
|
+
const url = getSavedApiUrl();
|
|
110
|
+
if (!url) {
|
|
111
|
+
throw new Error("未配置 API 地址。请运行 login 在登录页填写服务器地址,或设置 DATA_MGR_API_URL / config api-url。");
|
|
112
|
+
}
|
|
113
|
+
return url;
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
export function hasValidToken() {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Data Manager 登录</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
11
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
12
|
+
background: linear-gradient(135deg, #0f172a, #1e3a5f);
|
|
13
|
+
color: #e2e8f0;
|
|
14
|
+
}
|
|
15
|
+
.card {
|
|
16
|
+
width: min(420px, 92vw); padding: 2rem; border-radius: 16px;
|
|
17
|
+
background: rgba(15, 23, 42, 0.9); border: 1px solid #334155;
|
|
18
|
+
box-shadow: 0 20px 50px rgba(0,0,0,.35);
|
|
19
|
+
}
|
|
20
|
+
h1 { margin: 0 0 .25rem; font-size: 1.35rem; }
|
|
21
|
+
p { margin: 0 0 1.5rem; color: #94a3b8; font-size: .9rem; }
|
|
22
|
+
label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; }
|
|
23
|
+
input {
|
|
24
|
+
width: 100%; padding: .65rem .75rem; margin-bottom: 1rem;
|
|
25
|
+
border: 1px solid #475569; border-radius: 8px; background: #0f172a; color: #f8fafc;
|
|
26
|
+
font-size: 1rem;
|
|
27
|
+
}
|
|
28
|
+
input:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
|
|
29
|
+
button {
|
|
30
|
+
width: 100%; padding: .75rem; border: none; border-radius: 8px;
|
|
31
|
+
background: #2563eb; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
button:hover { background: #1d4ed8; }
|
|
34
|
+
button:disabled { opacity: .6; cursor: not-allowed; }
|
|
35
|
+
.msg { margin-top: 1rem; font-size: .9rem; min-height: 1.25rem; }
|
|
36
|
+
.err { color: #f87171; }
|
|
37
|
+
.ok { color: #4ade80; }
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<div class="card">
|
|
42
|
+
<h1>Data Manager</h1>
|
|
43
|
+
<p>请输入 API 地址、账号和密码。登录成功后将保存服务器地址与 token。</p>
|
|
44
|
+
<form id="form">
|
|
45
|
+
<label for="apiUrl">API 地址</label>
|
|
46
|
+
<input id="apiUrl" name="apiUrl" type="text" inputmode="url" spellcheck="false"
|
|
47
|
+
placeholder="http://host:8092" required autofocus />
|
|
48
|
+
<label for="username">账号</label>
|
|
49
|
+
<input id="username" name="username" autocomplete="username" required />
|
|
50
|
+
<label for="password">密码</label>
|
|
51
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
|
52
|
+
<button type="submit" id="btn">登录</button>
|
|
53
|
+
</form>
|
|
54
|
+
<div id="msg" class="msg"></div>
|
|
55
|
+
</div>
|
|
56
|
+
<script>
|
|
57
|
+
const form = document.getElementById('form');
|
|
58
|
+
const msg = document.getElementById('msg');
|
|
59
|
+
const btn = document.getElementById('btn');
|
|
60
|
+
const apiUrlInput = document.getElementById('apiUrl');
|
|
61
|
+
const defaultApiUrl = __DEFAULT_API_URL_JSON__;
|
|
62
|
+
if (defaultApiUrl) apiUrlInput.value = defaultApiUrl;
|
|
63
|
+
else {
|
|
64
|
+
try {
|
|
65
|
+
const last = localStorage.getItem('data-mgr-api-url');
|
|
66
|
+
if (last) apiUrlInput.value = last;
|
|
67
|
+
} catch (_) {}
|
|
68
|
+
}
|
|
69
|
+
form.addEventListener('submit', async (e) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
msg.textContent = '';
|
|
72
|
+
msg.className = 'msg';
|
|
73
|
+
btn.disabled = true;
|
|
74
|
+
btn.textContent = '登录中…';
|
|
75
|
+
const apiUrl = apiUrlInput.value.trim();
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch('/api/login', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
apiUrl,
|
|
82
|
+
username: document.getElementById('username').value,
|
|
83
|
+
password: document.getElementById('password').value,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const data = await res.json().catch(() => ({}));
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
throw new Error(data.message || data.error || ('HTTP ' + res.status));
|
|
89
|
+
}
|
|
90
|
+
try { localStorage.setItem('data-mgr-api-url', apiUrl); } catch (_) {}
|
|
91
|
+
window.location.href = '/success';
|
|
92
|
+
} catch (err) {
|
|
93
|
+
msg.textContent = err.message || '登录失败';
|
|
94
|
+
msg.className = 'msg err';
|
|
95
|
+
btn.disabled = false;
|
|
96
|
+
btn.textContent = '登录';
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
package/lib/login-server.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
2
5
|
import { randomBytes } from "node:crypto";
|
|
3
6
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
7
|
+
import { getSavedApiUrl, parseApiUrl, LOGIN_PATH } from "./config.js";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const LOGIN_PAGE_PATH = path.join(__dirname, "login-page.html");
|
|
5
11
|
|
|
6
12
|
const CORS_HEADERS = {
|
|
7
13
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -9,6 +15,17 @@ const CORS_HEADERS = {
|
|
|
9
15
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
10
16
|
};
|
|
11
17
|
|
|
18
|
+
const NO_CACHE_HEADERS = {
|
|
19
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
20
|
+
Pragma: "no-cache",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** @param {string} [defaultApiUrl] */
|
|
24
|
+
function renderLoginPage(defaultApiUrl) {
|
|
25
|
+
const template = fs.readFileSync(LOGIN_PAGE_PATH, "utf8");
|
|
26
|
+
return template.replace("__DEFAULT_API_URL_JSON__", JSON.stringify(defaultApiUrl ?? ""));
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/** @param {import('node:http').ServerResponse} res @param {number} status @param {Record<string, unknown>} body */
|
|
13
30
|
function sendJson(res, status, body) {
|
|
14
31
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...CORS_HEADERS });
|
|
@@ -37,95 +54,6 @@ function formatUpstreamError(err, apiUrl) {
|
|
|
37
54
|
return msg || base.message;
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
const LOGIN_PAGE = String.raw`<!DOCTYPE html>
|
|
41
|
-
<html lang="zh-CN">
|
|
42
|
-
<head>
|
|
43
|
-
<meta charset="UTF-8" />
|
|
44
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
45
|
-
<title>Data Manager 登录</title>
|
|
46
|
-
<style>
|
|
47
|
-
* { box-sizing: border-box; }
|
|
48
|
-
body {
|
|
49
|
-
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
50
|
-
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
51
|
-
background: linear-gradient(135deg, #0f172a, #1e3a5f);
|
|
52
|
-
color: #e2e8f0;
|
|
53
|
-
}
|
|
54
|
-
.card {
|
|
55
|
-
width: min(400px, 92vw); padding: 2rem; border-radius: 16px;
|
|
56
|
-
background: rgba(15, 23, 42, 0.9); border: 1px solid #334155;
|
|
57
|
-
box-shadow: 0 20px 50px rgba(0,0,0,.35);
|
|
58
|
-
}
|
|
59
|
-
h1 { margin: 0 0 .25rem; font-size: 1.35rem; }
|
|
60
|
-
p { margin: 0 0 1.5rem; color: #94a3b8; font-size: .9rem; }
|
|
61
|
-
label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; }
|
|
62
|
-
input {
|
|
63
|
-
width: 100%; padding: .65rem .75rem; margin-bottom: 1rem;
|
|
64
|
-
border: 1px solid #475569; border-radius: 8px; background: #0f172a; color: #f8fafc;
|
|
65
|
-
font-size: 1rem;
|
|
66
|
-
}
|
|
67
|
-
input:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
|
|
68
|
-
button {
|
|
69
|
-
width: 100%; padding: .75rem; border: none; border-radius: 8px;
|
|
70
|
-
background: #2563eb; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer;
|
|
71
|
-
}
|
|
72
|
-
button:hover { background: #1d4ed8; }
|
|
73
|
-
button:disabled { opacity: .6; cursor: not-allowed; }
|
|
74
|
-
.msg { margin-top: 1rem; font-size: .9rem; min-height: 1.25rem; }
|
|
75
|
-
.err { color: #f87171; }
|
|
76
|
-
.ok { color: #4ade80; }
|
|
77
|
-
.api { font-size: .75rem; color: #64748b; word-break: break-all; margin-top: 1rem; }
|
|
78
|
-
</style>
|
|
79
|
-
</head>
|
|
80
|
-
<body>
|
|
81
|
-
<div class="card">
|
|
82
|
-
<h1>Data Manager</h1>
|
|
83
|
-
<p>请输入账号和密码,登录成功后 CLI 将自动保存 token。</p>
|
|
84
|
-
<form id="form">
|
|
85
|
-
<label for="username">账号</label>
|
|
86
|
-
<input id="username" name="username" autocomplete="username" required />
|
|
87
|
-
<label for="password">密码</label>
|
|
88
|
-
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
|
89
|
-
<button type="submit" id="btn">登录</button>
|
|
90
|
-
</form>
|
|
91
|
-
<div id="msg" class="msg"></div>
|
|
92
|
-
<div class="api">API: __API_URL__</div>
|
|
93
|
-
</div>
|
|
94
|
-
<script>
|
|
95
|
-
const form = document.getElementById('form');
|
|
96
|
-
const msg = document.getElementById('msg');
|
|
97
|
-
const btn = document.getElementById('btn');
|
|
98
|
-
form.addEventListener('submit', async (e) => {
|
|
99
|
-
e.preventDefault();
|
|
100
|
-
msg.textContent = '';
|
|
101
|
-
msg.className = 'msg';
|
|
102
|
-
btn.disabled = true;
|
|
103
|
-
btn.textContent = '登录中…';
|
|
104
|
-
try {
|
|
105
|
-
const res = await fetch('/api/login', {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
headers: { 'Content-Type': 'application/json' },
|
|
108
|
-
body: JSON.stringify({
|
|
109
|
-
username: form.username.value,
|
|
110
|
-
password: form.password.value,
|
|
111
|
-
}),
|
|
112
|
-
});
|
|
113
|
-
const data = await res.json().catch(() => ({}));
|
|
114
|
-
if (!res.ok) {
|
|
115
|
-
throw new Error(data.message || data.error || ('HTTP ' + res.status));
|
|
116
|
-
}
|
|
117
|
-
window.location.href = '/success';
|
|
118
|
-
} catch (err) {
|
|
119
|
-
msg.textContent = err.message || '登录失败';
|
|
120
|
-
msg.className = 'msg err';
|
|
121
|
-
btn.disabled = false;
|
|
122
|
-
btn.textContent = '登录';
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
</script>
|
|
126
|
-
</body>
|
|
127
|
-
</html>`;
|
|
128
|
-
|
|
129
57
|
const SUCCESS_PAGE = String.raw`<!DOCTYPE html>
|
|
130
58
|
<html lang="zh-CN">
|
|
131
59
|
<head>
|
|
@@ -152,18 +80,17 @@ const SUCCESS_PAGE = String.raw`<!DOCTYPE html>
|
|
|
152
80
|
/**
|
|
153
81
|
* @typedef {object} LoginResult
|
|
154
82
|
* @property {string} token
|
|
83
|
+
* @property {string} apiUrl
|
|
155
84
|
* @property {string} [username]
|
|
156
85
|
* @property {number} [userId]
|
|
157
86
|
* @property {number} [tenantId]
|
|
158
87
|
* @property {string} [expiresIn]
|
|
159
88
|
* @property {string} [expiresAt]
|
|
160
89
|
* @property {string} loggedInAt
|
|
161
|
-
* @property {string} loginUrl
|
|
90
|
+
* @property {string} loginUrl
|
|
162
91
|
*/
|
|
163
92
|
|
|
164
93
|
/**
|
|
165
|
-
* Start local login server, open browser, return token when login succeeds.
|
|
166
|
-
* Modified for MCP: uses onLoginUrl callback instead of console.log.
|
|
167
94
|
* @param {{ timeoutMs?: number, openBrowser?: boolean, apiUrl?: string, onLoginUrl?: (url: string) => void }} [options]
|
|
168
95
|
* @returns {Promise<LoginResult>}
|
|
169
96
|
*/
|
|
@@ -171,7 +98,7 @@ export function runBrowserLogin(options = {}) {
|
|
|
171
98
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
172
99
|
const openBrowser = options.openBrowser !== false;
|
|
173
100
|
const onLoginUrl = options.onLoginUrl ?? (() => {});
|
|
174
|
-
const
|
|
101
|
+
const defaultApiUrl = options.apiUrl ?? getSavedApiUrl() ?? "";
|
|
175
102
|
const state = randomBytes(16).toString("hex");
|
|
176
103
|
|
|
177
104
|
return new Promise((resolve, reject) => {
|
|
@@ -195,8 +122,11 @@ export function runBrowserLogin(options = {}) {
|
|
|
195
122
|
}
|
|
196
123
|
|
|
197
124
|
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/login")) {
|
|
198
|
-
res.writeHead(200, {
|
|
199
|
-
|
|
125
|
+
res.writeHead(200, {
|
|
126
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
127
|
+
...NO_CACHE_HEADERS,
|
|
128
|
+
});
|
|
129
|
+
res.end(renderLoginPage(defaultApiUrl));
|
|
200
130
|
return;
|
|
201
131
|
}
|
|
202
132
|
|
|
@@ -204,14 +134,22 @@ export function runBrowserLogin(options = {}) {
|
|
|
204
134
|
let body = "";
|
|
205
135
|
req.on("data", (chunk) => { body += chunk; });
|
|
206
136
|
req.on("end", async () => {
|
|
137
|
+
let loginApiUrl = defaultApiUrl;
|
|
207
138
|
try {
|
|
208
|
-
const { username, password } = JSON.parse(body || "{}");
|
|
139
|
+
const { username, password, apiUrl: rawApiUrl } = JSON.parse(body || "{}");
|
|
140
|
+
try {
|
|
141
|
+
loginApiUrl = parseApiUrl(rawApiUrl);
|
|
142
|
+
} catch (parseErr) {
|
|
143
|
+
const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
144
|
+
sendJson(res, 400, { error: message, message });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
209
147
|
if (!username || !password) {
|
|
210
148
|
sendJson(res, 400, { error: "username and password required" });
|
|
211
149
|
return;
|
|
212
150
|
}
|
|
213
151
|
|
|
214
|
-
const loginEndpoint = `${
|
|
152
|
+
const loginEndpoint = `${loginApiUrl}${LOGIN_PATH}`;
|
|
215
153
|
let loginRes;
|
|
216
154
|
try {
|
|
217
155
|
loginRes = await fetch(loginEndpoint, {
|
|
@@ -221,22 +159,20 @@ export function runBrowserLogin(options = {}) {
|
|
|
221
159
|
});
|
|
222
160
|
} catch (fetchErr) {
|
|
223
161
|
sendJson(res, 502, {
|
|
224
|
-
error: formatUpstreamError(fetchErr,
|
|
225
|
-
message: formatUpstreamError(fetchErr,
|
|
162
|
+
error: formatUpstreamError(fetchErr, loginApiUrl),
|
|
163
|
+
message: formatUpstreamError(fetchErr, loginApiUrl),
|
|
226
164
|
});
|
|
227
165
|
return;
|
|
228
166
|
}
|
|
229
167
|
|
|
230
168
|
const data = await loginRes.json().catch(() => ({}));
|
|
231
169
|
|
|
232
|
-
// API returns {code, message, data} — check code for errors
|
|
233
170
|
if (!loginRes.ok || (data.code != null && data.code !== 0)) {
|
|
234
171
|
const message = data.message ?? data.error ?? `Login failed (HTTP ${loginRes.status}, code ${data.code})`;
|
|
235
172
|
sendJson(res, loginRes.ok ? 401 : loginRes.status, { error: message, message });
|
|
236
173
|
return;
|
|
237
174
|
}
|
|
238
175
|
|
|
239
|
-
// Token may be at data.token, data.data.token, or data.data.access_token
|
|
240
176
|
const inner = data.data ?? {};
|
|
241
177
|
const token = data.token ?? inner.token ?? inner.access_token;
|
|
242
178
|
if (!token) {
|
|
@@ -257,6 +193,7 @@ export function runBrowserLogin(options = {}) {
|
|
|
257
193
|
cleanup();
|
|
258
194
|
resolve({
|
|
259
195
|
token,
|
|
196
|
+
apiUrl: loginApiUrl,
|
|
260
197
|
username: inner.username ?? data.username ?? username,
|
|
261
198
|
userId: inner.user_id ?? data.user_id,
|
|
262
199
|
tenantId: inner.tenant_id ?? data.tenant_id,
|
|
@@ -265,7 +202,7 @@ export function runBrowserLogin(options = {}) {
|
|
|
265
202
|
loginUrl: `http://127.0.0.1:${server?.address()?.port ?? "?"}/login`,
|
|
266
203
|
});
|
|
267
204
|
} catch (err) {
|
|
268
|
-
const message = formatUpstreamError(err,
|
|
205
|
+
const message = formatUpstreamError(err, loginApiUrl);
|
|
269
206
|
console.error("[data-mgr login]", message, err);
|
|
270
207
|
sendJson(res, 500, { error: message, message });
|
|
271
208
|
}
|
|
@@ -274,7 +211,7 @@ export function runBrowserLogin(options = {}) {
|
|
|
274
211
|
}
|
|
275
212
|
|
|
276
213
|
if (req.method === "GET" && url.pathname === "/success") {
|
|
277
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
214
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...NO_CACHE_HEADERS });
|
|
278
215
|
res.end(SUCCESS_PAGE);
|
|
279
216
|
return;
|
|
280
217
|
}
|
|
@@ -292,6 +229,7 @@ export function runBrowserLogin(options = {}) {
|
|
|
292
229
|
cleanup();
|
|
293
230
|
resolve({
|
|
294
231
|
token,
|
|
232
|
+
apiUrl: defaultApiUrl,
|
|
295
233
|
username: url.searchParams.get("username") ?? undefined,
|
|
296
234
|
loggedInAt: new Date().toISOString(),
|
|
297
235
|
loginUrl: `http://127.0.0.1:${server?.address()?.port ?? "?"}/login`,
|
|
@@ -313,8 +251,6 @@ export function runBrowserLogin(options = {}) {
|
|
|
313
251
|
}
|
|
314
252
|
|
|
315
253
|
const loginUrl = `http://127.0.0.1:${addr.port}/login`;
|
|
316
|
-
|
|
317
|
-
// Notify caller of the login URL (replaces console.log for MCP compatibility)
|
|
318
254
|
onLoginUrl(loginUrl);
|
|
319
255
|
|
|
320
256
|
if (openBrowser) {
|
|
@@ -358,9 +294,7 @@ function openUrl(url) {
|
|
|
358
294
|
["-NoProfile", "-Command", `Start-Process -FilePath '${url.replace(/'/g, "''")}'`],
|
|
359
295
|
{ detached: true, stdio: "ignore", windowsHide: true },
|
|
360
296
|
).unref();
|
|
361
|
-
return;
|
|
362
297
|
}
|
|
363
|
-
// Browser open failed - caller already has the URL via onLoginUrl callback
|
|
364
298
|
});
|
|
365
299
|
|
|
366
300
|
child.unref();
|
package/lib/tools.js
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
import { z } from "zod";
|
|
2
5
|
import {
|
|
3
6
|
loadConfig,
|
|
4
7
|
saveConfig,
|
|
5
|
-
|
|
8
|
+
getSavedApiUrl,
|
|
6
9
|
hasValidToken,
|
|
7
10
|
computeExpiresAt,
|
|
8
11
|
getConfigPath,
|
|
9
12
|
} from "./config.js";
|
|
10
13
|
import { apiRequest, buildQuery } from "./api.js";
|
|
11
14
|
|
|
15
|
+
const LOGIN_SCRIPT = path.join(
|
|
16
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
17
|
+
"..",
|
|
18
|
+
"bin",
|
|
19
|
+
"data-mgr-login.js",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** @param {boolean} openBrowser */
|
|
23
|
+
function runLoginSubprocess(openBrowser) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const args = [LOGIN_SCRIPT];
|
|
26
|
+
if (!openBrowser) args.push("--no-open");
|
|
27
|
+
const child = spawn(process.execPath, args, {
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
});
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
34
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
35
|
+
child.on("error", reject);
|
|
36
|
+
child.on("close", (code) => {
|
|
37
|
+
if (code !== 0) {
|
|
38
|
+
reject(new Error(stderr.trim() || `Login exited with code ${code}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
resolve(JSON.parse(stdout.trim()));
|
|
43
|
+
} catch {
|
|
44
|
+
reject(new Error("Invalid login response from subprocess"));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
/**
|
|
13
51
|
* Register all data-mgr tools on the MCP server.
|
|
14
52
|
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
@@ -125,7 +163,7 @@ export function registerTools(server) {
|
|
|
125
163
|
{},
|
|
126
164
|
async () => {
|
|
127
165
|
const cfg = loadConfig();
|
|
128
|
-
const apiUrl =
|
|
166
|
+
const apiUrl = getSavedApiUrl() ?? "(未配置,登录时填写)";
|
|
129
167
|
const loggedIn = hasValidToken();
|
|
130
168
|
|
|
131
169
|
const expiresAt =
|
|
@@ -165,26 +203,19 @@ export function registerTools(server) {
|
|
|
165
203
|
// Tool 4: login — trigger browser login flow
|
|
166
204
|
server.tool(
|
|
167
205
|
"login",
|
|
168
|
-
"Trigger
|
|
206
|
+
"Trigger browser login. User enters API base URL, username, and password on the local login page; apiUrl and token are saved to ~/.data-mgr/config.json. Returns login URL if the browser cannot open.",
|
|
169
207
|
{
|
|
170
208
|
openBrowser: z.boolean().optional().describe("Whether to open browser automatically (default true)"),
|
|
171
209
|
},
|
|
172
210
|
async ({ openBrowser }) => {
|
|
173
211
|
try {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
// Collect login URL from the callback
|
|
177
|
-
let loginUrl = "";
|
|
178
|
-
const result = await runBrowserLogin({
|
|
179
|
-
openBrowser: openBrowser !== false,
|
|
180
|
-
timeoutMs: 5 * 60 * 1000,
|
|
181
|
-
onLoginUrl: (url) => { loginUrl = url; },
|
|
182
|
-
});
|
|
212
|
+
const result = await runLoginSubprocess(openBrowser !== false);
|
|
213
|
+
const loginUrl = result.loginUrl;
|
|
183
214
|
|
|
184
215
|
const expiresAt = computeExpiresAt(result.expiresIn, new Date(result.loggedInAt));
|
|
185
216
|
|
|
186
217
|
saveConfig({
|
|
187
|
-
apiUrl:
|
|
218
|
+
apiUrl: result.apiUrl,
|
|
188
219
|
token: result.token,
|
|
189
220
|
username: result.username,
|
|
190
221
|
userId: result.userId,
|
|
@@ -195,6 +226,7 @@ export function registerTools(server) {
|
|
|
195
226
|
});
|
|
196
227
|
|
|
197
228
|
const lines = [
|
|
229
|
+
`API URL: ${result.apiUrl}`,
|
|
198
230
|
`Logged in as: ${result.username ?? "(unknown)"}`,
|
|
199
231
|
`User ID: ${result.userId}`,
|
|
200
232
|
`Tenant ID: ${result.tenantId}`,
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wsh19991219/mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "MCP server for Data Manager API — stdio transport, JWT auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/tools.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"data-mgr-mcp": "bin/data-mgr-mcp.js"
|
|
8
|
+
"data-mgr-mcp": "bin/data-mgr-mcp.js",
|
|
9
|
+
"data-mgr-login": "bin/data-mgr-login.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"bin",
|