@wsh19991219/mcp-server 0.1.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/README.md +45 -0
- package/SKILL.md +89 -0
- package/bin/data-mgr-mcp.js +15 -0
- package/lib/api.js +49 -0
- package/lib/config.js +87 -0
- package/lib/login-server.js +320 -0
- package/lib/tools.js +214 -0
- package/package.json +26 -0
- package/skills/daily-channel-report/SKILL.md +94 -0
- package/skills/data-analysis/SKILL.md +122 -0
- package/skills/data-query/SKILL.md +102 -0
- package/skills/schema-explorer/SKILL.md +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @data_mgr/mcp-server
|
|
2
|
+
|
|
3
|
+
Data Manager MCP Server — 通过 MCP 协议对接 Data Manager 后端 API,提供数据分析查询能力。
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @data_mgr/mcp-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires **Node.js 18+**.
|
|
12
|
+
|
|
13
|
+
## Claude Code 集成
|
|
14
|
+
|
|
15
|
+
在 `.claude/settings.json` 中添加:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"data-mgr": {
|
|
21
|
+
"command": "data-mgr-mcp"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## MCP 工具
|
|
28
|
+
|
|
29
|
+
| 工具 | 功能 |
|
|
30
|
+
|------|------|
|
|
31
|
+
| `check_auth` | 检查登录状态 |
|
|
32
|
+
| `login` | 浏览器登录 |
|
|
33
|
+
| `analytics_query` | 预定义指标查询 |
|
|
34
|
+
| `api_request` | 通用 API 请求(含自定义 SQL) |
|
|
35
|
+
|
|
36
|
+
## 环境变量
|
|
37
|
+
|
|
38
|
+
| 变量 | 说明 | 默认值 |
|
|
39
|
+
|------|------|--------|
|
|
40
|
+
| `DATA_MGR_API_URL` | 后端 API 地址 | `http://127.0.0.1:8092` |
|
|
41
|
+
| `DATA_MGR_CONFIG_DIR` | 配置文件目录 | `~/.data-mgr` |
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: data-mgr-mcp
|
|
3
|
+
description: >-
|
|
4
|
+
Data Manager 数据分析网关 MCP 服务。通过 MCP 工具对接 data-mgr 后端,
|
|
5
|
+
提供鉴权、表结构探索、数据查询、自定义 SQL 能力。
|
|
6
|
+
子 Skill: schema-explorer(查表), data-query(查数据), data-analysis(分析流程), daily-channel-report(日报)。
|
|
7
|
+
Triggers: data-mgr, data_mgr, analytics, 数据管理, 数据分析, 数据查询, 查表, 报表.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Data Manager MCP Server
|
|
11
|
+
|
|
12
|
+
通过 MCP 协议对接 Data Manager 后端 API,提供数据分析查询能力。
|
|
13
|
+
|
|
14
|
+
## MCP 工具总览
|
|
15
|
+
|
|
16
|
+
| 工具 | 功能 | 关键参数 |
|
|
17
|
+
|------|------|---------|
|
|
18
|
+
| `check_auth` | 检查登录状态 | 无 |
|
|
19
|
+
| `login` | 浏览器登录 | `openBrowser` (可选) |
|
|
20
|
+
| `analytics_query` | 预定义指标查询 | `metric`(必填), `db`, `params`, `filters`, `limit` |
|
|
21
|
+
| `api_request` | 通用 API 请求 | `method`(必填), `path`(必填), `body`, `query` |
|
|
22
|
+
|
|
23
|
+
## 通过 api_request 可访问的 REST 端点
|
|
24
|
+
|
|
25
|
+
| 端点 | 方法 | 用途 | 对应 Skill |
|
|
26
|
+
|------|------|------|-----------|
|
|
27
|
+
| `/api/v1/analytics/catalog` | GET | 列出可用指标和数据库 group | data-query, data-analysis |
|
|
28
|
+
| `/api/v1/analytics/schema?q=<关键词>` | GET | 模糊搜表名 | schema-explorer |
|
|
29
|
+
| `/api/v1/analytics/schema?tables=<表名>` | GET | 查看表列结构 | schema-explorer |
|
|
30
|
+
| `/api/v1/analytics/sql` | POST | 自定义只读 SQL | data-query, data-analysis |
|
|
31
|
+
| `/health` | GET | 健康检查 | — |
|
|
32
|
+
|
|
33
|
+
## 子 Skill 列表
|
|
34
|
+
|
|
35
|
+
| Skill | 触发词 | 用途 |
|
|
36
|
+
|-------|--------|------|
|
|
37
|
+
| `schema-explorer` | 查表/表结构/搜表/字段 | 表结构探索 |
|
|
38
|
+
| `data-query` | 查数据/指标/统计/SQL | 数据查询(指标 + SQL) |
|
|
39
|
+
| `data-analysis` | 数据分析/对账/报表/漏斗 | 六步分析流程 |
|
|
40
|
+
| `daily-channel-report` | 日报/每日报表/昨日交易 | 渠道交易日报 |
|
|
41
|
+
|
|
42
|
+
## 标准工作流
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
1. check_auth → 检查是否已登录
|
|
46
|
+
2. login → 未登录时触发浏览器登录
|
|
47
|
+
3. catalog/schema → 定位数据(api_request GET)
|
|
48
|
+
4. query/sql → 取数(analytics_query 或 api_request POST)
|
|
49
|
+
5. 格式化交付 → 结构化结论
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 注册到 Claude Code
|
|
53
|
+
|
|
54
|
+
在 `.claude/settings.json` 中添加:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"data-mgr": {
|
|
60
|
+
"command": "node",
|
|
61
|
+
"args": ["$(npm root -g)/@data_mgr/mcp-server/bin/data-mgr-mcp.js"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 环境变量
|
|
68
|
+
|
|
69
|
+
| 变量 | 说明 | 默认值 |
|
|
70
|
+
|------|------|--------|
|
|
71
|
+
| `DATA_MGR_API_URL` | 后端 API 地址 | `http://127.0.0.1:8092` |
|
|
72
|
+
| `DATA_MGR_CONFIG_DIR` | 配置文件目录 | `~/.data-mgr` |
|
|
73
|
+
|
|
74
|
+
## 错误处理
|
|
75
|
+
|
|
76
|
+
| 错误 | 处理 |
|
|
77
|
+
|------|------|
|
|
78
|
+
| `Not logged in` | 调用 `login` 工具 |
|
|
79
|
+
| `Login timed out` | 重新调用 `login`,手动打开返回的 URL |
|
|
80
|
+
| Token 过期 | 重新调用 `login` |
|
|
81
|
+
| API 403/401 | 检查 token 或重新登录 |
|
|
82
|
+
| API 参数错误 | 检查 metric/db/SQL 参数 |
|
|
83
|
+
|
|
84
|
+
## 安全
|
|
85
|
+
|
|
86
|
+
- **禁止**在回复中输出完整 token
|
|
87
|
+
- 写/删操作前确认用户意图
|
|
88
|
+
- SQL 仅允许 SELECT/WITH(只读),禁止 DML、注释、多语句
|
|
89
|
+
- 敏感数据(手机/身份证/邮箱/银行卡)会被后端自动脱敏
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { registerTools } from "../lib/tools.js";
|
|
6
|
+
|
|
7
|
+
const server = new McpServer({
|
|
8
|
+
name: "data-mgr",
|
|
9
|
+
version: "0.1.0",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
registerTools(server);
|
|
13
|
+
|
|
14
|
+
const transport = new StdioServerTransport();
|
|
15
|
+
await server.connect(transport);
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getApiUrl, loadConfig, hasValidToken } from "./config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} path - API path starting with /
|
|
5
|
+
* @param {RequestInit} [init]
|
|
6
|
+
*/
|
|
7
|
+
export async function apiRequest(path, init = {}) {
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
if (!hasValidToken()) {
|
|
10
|
+
throw new Error("Not logged in. Run: data-mgr login");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const base = getApiUrl();
|
|
14
|
+
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
|
15
|
+
const headers = new Headers(init.headers);
|
|
16
|
+
headers.set("Authorization", `Bearer ${cfg.token}`);
|
|
17
|
+
if (init.body && !headers.has("Content-Type")) {
|
|
18
|
+
headers.set("Content-Type", "application/json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const res = await fetch(url, { ...init, headers });
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
let data;
|
|
24
|
+
try {
|
|
25
|
+
data = text ? JSON.parse(text) : null;
|
|
26
|
+
} catch {
|
|
27
|
+
data = text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const msg =
|
|
32
|
+
typeof data === "object" && data && ("message" in data || "error" in data)
|
|
33
|
+
? String(data.message ?? data.error)
|
|
34
|
+
: `HTTP ${res.status}`;
|
|
35
|
+
const err = new Error(msg);
|
|
36
|
+
err.status = res.status;
|
|
37
|
+
err.body = data;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {Record<string, string>} [query] */
|
|
45
|
+
export function buildQuery(query) {
|
|
46
|
+
if (!query || Object.keys(query).length === 0) return "";
|
|
47
|
+
const params = new URLSearchParams(query);
|
|
48
|
+
return `?${params.toString()}`;
|
|
49
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = process.env.DATA_MGR_CONFIG_DIR
|
|
6
|
+
?? path.join(os.homedir(), ".data-mgr");
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
|
+
|
|
9
|
+
/** @typedef {{ apiUrl?: string, token?: string, username?: string, userId?: number, tenantId?: number, expiresIn?: string, expiresAt?: string, loggedInAt?: string }} DataMgrConfig */
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_API_URL = "http://127.0.0.1:8092";
|
|
12
|
+
export const LOGIN_PATH = "/api/v1/auth/login";
|
|
13
|
+
|
|
14
|
+
/** @param {string | number | undefined} expiresIn @param {Date} [from] */
|
|
15
|
+
export function computeExpiresAt(expiresIn, from = new Date()) {
|
|
16
|
+
if (expiresIn === undefined || expiresIn === null || expiresIn === "") return undefined;
|
|
17
|
+
const seconds = Number(expiresIn);
|
|
18
|
+
if (!Number.isNaN(seconds) && seconds > 0) {
|
|
19
|
+
return new Date(from.getTime() + seconds * 1000).toISOString();
|
|
20
|
+
}
|
|
21
|
+
const asDate = new Date(String(expiresIn));
|
|
22
|
+
if (!Number.isNaN(asDate.getTime())) return asDate.toISOString();
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @returns {DataMgrConfig} */
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {Partial<DataMgrConfig>} patch */
|
|
37
|
+
export function saveConfig(patch) {
|
|
38
|
+
const current = loadConfig();
|
|
39
|
+
const next = { ...current, ...patch };
|
|
40
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
41
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), "utf8");
|
|
42
|
+
try {
|
|
43
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
44
|
+
} catch {
|
|
45
|
+
// Windows may not support chmod the same way
|
|
46
|
+
}
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearToken() {
|
|
51
|
+
const {
|
|
52
|
+
token,
|
|
53
|
+
username,
|
|
54
|
+
userId,
|
|
55
|
+
tenantId,
|
|
56
|
+
expiresIn,
|
|
57
|
+
expiresAt,
|
|
58
|
+
loggedInAt,
|
|
59
|
+
...rest
|
|
60
|
+
} = loadConfig();
|
|
61
|
+
saveConfig(rest);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getConfigPath() {
|
|
65
|
+
return CONFIG_FILE;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {string} */
|
|
69
|
+
export function getApiUrl() {
|
|
70
|
+
const fromEnv = process.env.DATA_MGR_API_URL?.trim();
|
|
71
|
+
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
|
72
|
+
const cfg = loadConfig();
|
|
73
|
+
if (cfg.apiUrl) return cfg.apiUrl.replace(/\/$/, "");
|
|
74
|
+
return DEFAULT_API_URL;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function hasValidToken() {
|
|
78
|
+
const cfg = loadConfig();
|
|
79
|
+
if (!cfg.token) return false;
|
|
80
|
+
const expiresAt =
|
|
81
|
+
cfg.expiresAt ??
|
|
82
|
+
(cfg.expiresIn && cfg.loggedInAt
|
|
83
|
+
? computeExpiresAt(cfg.expiresIn, new Date(cfg.loggedInAt))
|
|
84
|
+
: undefined);
|
|
85
|
+
if (!expiresAt) return true;
|
|
86
|
+
return new Date(expiresAt).getTime() > Date.now();
|
|
87
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { getApiUrl, LOGIN_PATH } from "./config.js";
|
|
5
|
+
|
|
6
|
+
const LOGIN_PAGE = String.raw`<!DOCTYPE html>
|
|
7
|
+
<html lang="zh-CN">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="UTF-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<title>Data Manager 登录</title>
|
|
12
|
+
<style>
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body {
|
|
15
|
+
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
16
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
background: linear-gradient(135deg, #0f172a, #1e3a5f);
|
|
18
|
+
color: #e2e8f0;
|
|
19
|
+
}
|
|
20
|
+
.card {
|
|
21
|
+
width: min(400px, 92vw); padding: 2rem; border-radius: 16px;
|
|
22
|
+
background: rgba(15, 23, 42, 0.9); border: 1px solid #334155;
|
|
23
|
+
box-shadow: 0 20px 50px rgba(0,0,0,.35);
|
|
24
|
+
}
|
|
25
|
+
h1 { margin: 0 0 .25rem; font-size: 1.35rem; }
|
|
26
|
+
p { margin: 0 0 1.5rem; color: #94a3b8; font-size: .9rem; }
|
|
27
|
+
label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; }
|
|
28
|
+
input {
|
|
29
|
+
width: 100%; padding: .65rem .75rem; margin-bottom: 1rem;
|
|
30
|
+
border: 1px solid #475569; border-radius: 8px; background: #0f172a; color: #f8fafc;
|
|
31
|
+
font-size: 1rem;
|
|
32
|
+
}
|
|
33
|
+
input:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
|
|
34
|
+
button {
|
|
35
|
+
width: 100%; padding: .75rem; border: none; border-radius: 8px;
|
|
36
|
+
background: #2563eb; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
button:hover { background: #1d4ed8; }
|
|
39
|
+
button:disabled { opacity: .6; cursor: not-allowed; }
|
|
40
|
+
.msg { margin-top: 1rem; font-size: .9rem; min-height: 1.25rem; }
|
|
41
|
+
.err { color: #f87171; }
|
|
42
|
+
.ok { color: #4ade80; }
|
|
43
|
+
.api { font-size: .75rem; color: #64748b; word-break: break-all; margin-top: 1rem; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="card">
|
|
48
|
+
<h1>Data Manager</h1>
|
|
49
|
+
<p>请输入账号和密码,登录成功后 CLI 将自动保存 token。</p>
|
|
50
|
+
<form id="form">
|
|
51
|
+
<label for="username">账号</label>
|
|
52
|
+
<input id="username" name="username" autocomplete="username" required />
|
|
53
|
+
<label for="password">密码</label>
|
|
54
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
|
55
|
+
<button type="submit" id="btn">登录</button>
|
|
56
|
+
</form>
|
|
57
|
+
<div id="msg" class="msg"></div>
|
|
58
|
+
<div class="api">API: __API_URL__</div>
|
|
59
|
+
</div>
|
|
60
|
+
<script>
|
|
61
|
+
const form = document.getElementById('form');
|
|
62
|
+
const msg = document.getElementById('msg');
|
|
63
|
+
const btn = document.getElementById('btn');
|
|
64
|
+
form.addEventListener('submit', async (e) => {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
msg.textContent = '';
|
|
67
|
+
msg.className = 'msg';
|
|
68
|
+
btn.disabled = true;
|
|
69
|
+
btn.textContent = '登录中…';
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch('/api/login', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
username: form.username.value,
|
|
76
|
+
password: form.password.value,
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
const data = await res.json().catch(() => ({}));
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
throw new Error(data.message || data.error || ('HTTP ' + res.status));
|
|
82
|
+
}
|
|
83
|
+
window.location.href = '/success';
|
|
84
|
+
} catch (err) {
|
|
85
|
+
msg.textContent = err.message || '登录失败';
|
|
86
|
+
msg.className = 'msg err';
|
|
87
|
+
btn.disabled = false;
|
|
88
|
+
btn.textContent = '登录';
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
</script>
|
|
92
|
+
</body>
|
|
93
|
+
</html>`;
|
|
94
|
+
|
|
95
|
+
const SUCCESS_PAGE = String.raw`<!DOCTYPE html>
|
|
96
|
+
<html lang="zh-CN">
|
|
97
|
+
<head>
|
|
98
|
+
<meta charset="UTF-8" />
|
|
99
|
+
<title>登录成功</title>
|
|
100
|
+
<style>
|
|
101
|
+
body {
|
|
102
|
+
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
103
|
+
font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0;
|
|
104
|
+
text-align: center;
|
|
105
|
+
}
|
|
106
|
+
h1 { color: #4ade80; }
|
|
107
|
+
p { color: #94a3b8; }
|
|
108
|
+
</style>
|
|
109
|
+
</head>
|
|
110
|
+
<body>
|
|
111
|
+
<div>
|
|
112
|
+
<h1>✓ 登录成功</h1>
|
|
113
|
+
<p>Token 已保存到本地,可以关闭此窗口并返回终端。</p>
|
|
114
|
+
</div>
|
|
115
|
+
</body>
|
|
116
|
+
</html>`;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @typedef {object} LoginResult
|
|
120
|
+
* @property {string} token
|
|
121
|
+
* @property {string} [username]
|
|
122
|
+
* @property {number} [userId]
|
|
123
|
+
* @property {number} [tenantId]
|
|
124
|
+
* @property {string} [expiresIn]
|
|
125
|
+
* @property {string} [expiresAt]
|
|
126
|
+
* @property {string} loggedInAt
|
|
127
|
+
* @property {string} loginUrl - The URL the user should visit to log in
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Start local login server, open browser, return token when login succeeds.
|
|
132
|
+
* Modified for MCP: uses onLoginUrl callback instead of console.log.
|
|
133
|
+
* @param {{ timeoutMs?: number, openBrowser?: boolean, onLoginUrl?: (url: string) => void }} [options]
|
|
134
|
+
* @returns {Promise<LoginResult>}
|
|
135
|
+
*/
|
|
136
|
+
export function runBrowserLogin(options = {}) {
|
|
137
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
138
|
+
const openBrowser = options.openBrowser !== false;
|
|
139
|
+
const onLoginUrl = options.onLoginUrl ?? (() => {});
|
|
140
|
+
const apiUrl = getApiUrl();
|
|
141
|
+
const state = randomBytes(16).toString("hex");
|
|
142
|
+
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
/** @type {import('node:http').Server | null} */
|
|
145
|
+
let server = null;
|
|
146
|
+
/** @type {NodeJS.Timeout | null} */
|
|
147
|
+
let timer = null;
|
|
148
|
+
|
|
149
|
+
const cleanup = () => {
|
|
150
|
+
if (timer) clearTimeout(timer);
|
|
151
|
+
server?.close();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
server = http.createServer(async (req, res) => {
|
|
155
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
156
|
+
|
|
157
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/login")) {
|
|
158
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
159
|
+
res.end(LOGIN_PAGE.replace("__API_URL__", apiUrl));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (req.method === "POST" && url.pathname === "/api/login") {
|
|
164
|
+
let body = "";
|
|
165
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
166
|
+
req.on("end", async () => {
|
|
167
|
+
try {
|
|
168
|
+
const { username, password } = JSON.parse(body);
|
|
169
|
+
if (!username || !password) {
|
|
170
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
171
|
+
res.end(JSON.stringify({ error: "username and password required" }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const loginRes = await fetch(`${apiUrl}${LOGIN_PATH}`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
body: JSON.stringify({ username, password }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const data = await loginRes.json().catch(() => ({}));
|
|
182
|
+
|
|
183
|
+
// API returns {code, message, data} — check code for errors
|
|
184
|
+
if (!loginRes.ok || (data.code != null && data.code !== 0)) {
|
|
185
|
+
const message = data.message ?? data.error ?? `Login failed (HTTP ${loginRes.status}, code ${data.code})`;
|
|
186
|
+
res.writeHead(loginRes.ok ? 401 : loginRes.status, { "Content-Type": "application/json" });
|
|
187
|
+
res.end(JSON.stringify({ error: message }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Token may be at data.token, data.data.token, or data.data.access_token
|
|
192
|
+
const inner = data.data ?? {};
|
|
193
|
+
const token = data.token ?? inner.token ?? inner.access_token;
|
|
194
|
+
if (!token) {
|
|
195
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
196
|
+
res.end(JSON.stringify({
|
|
197
|
+
error: "API response missing token field",
|
|
198
|
+
debug: { topKeys: Object.keys(data), innerKeys: Object.keys(inner) },
|
|
199
|
+
}));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
204
|
+
res.end(JSON.stringify({ ok: true }));
|
|
205
|
+
|
|
206
|
+
const loggedInAt = new Date().toISOString();
|
|
207
|
+
const expiresIn = (inner.expires_in ?? data.expires_in) != null
|
|
208
|
+
? String(inner.expires_in ?? data.expires_in) : undefined;
|
|
209
|
+
|
|
210
|
+
cleanup();
|
|
211
|
+
resolve({
|
|
212
|
+
token,
|
|
213
|
+
username: inner.username ?? data.username ?? username,
|
|
214
|
+
userId: inner.user_id ?? data.user_id,
|
|
215
|
+
tenantId: inner.tenant_id ?? data.tenant_id,
|
|
216
|
+
expiresIn,
|
|
217
|
+
loggedInAt,
|
|
218
|
+
loginUrl: `http://127.0.0.1:${server?.address()?.port ?? "?"}/login`,
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
222
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
223
|
+
res.end(JSON.stringify({ error: message }));
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (req.method === "GET" && url.pathname === "/success") {
|
|
230
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
231
|
+
res.end(SUCCESS_PAGE);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (req.method === "GET" && url.pathname === "/callback") {
|
|
236
|
+
if (url.searchParams.get("state") && url.searchParams.get("state") !== state) {
|
|
237
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
238
|
+
res.end("Invalid state");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const token = url.searchParams.get("token") ?? url.searchParams.get("access_token");
|
|
242
|
+
if (token) {
|
|
243
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
244
|
+
res.end(SUCCESS_PAGE);
|
|
245
|
+
cleanup();
|
|
246
|
+
resolve({
|
|
247
|
+
token,
|
|
248
|
+
username: url.searchParams.get("username") ?? undefined,
|
|
249
|
+
loggedInAt: new Date().toISOString(),
|
|
250
|
+
loginUrl: `http://127.0.0.1:${server?.address()?.port ?? "?"}/login`,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
257
|
+
res.end("Not found");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
server.listen(0, "127.0.0.1", () => {
|
|
261
|
+
const addr = server.address();
|
|
262
|
+
if (!addr || typeof addr === "string") {
|
|
263
|
+
cleanup();
|
|
264
|
+
reject(new Error("Failed to bind login server"));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const loginUrl = `http://127.0.0.1:${addr.port}/login`;
|
|
269
|
+
|
|
270
|
+
// Notify caller of the login URL (replaces console.log for MCP compatibility)
|
|
271
|
+
onLoginUrl(loginUrl);
|
|
272
|
+
|
|
273
|
+
if (openBrowser) {
|
|
274
|
+
openUrl(loginUrl);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
|
+
cleanup();
|
|
279
|
+
reject(new Error("Login timed out. Open the URL above in your browser and try again."));
|
|
280
|
+
}, timeoutMs);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
server.on("error", (err) => {
|
|
284
|
+
cleanup();
|
|
285
|
+
reject(err);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {string} url */
|
|
291
|
+
function openUrl(url) {
|
|
292
|
+
const platform = process.platform;
|
|
293
|
+
let child;
|
|
294
|
+
|
|
295
|
+
if (platform === "win32") {
|
|
296
|
+
child = spawn("cmd.exe", ["/c", "start", "", url], {
|
|
297
|
+
detached: true,
|
|
298
|
+
stdio: "ignore",
|
|
299
|
+
windowsHide: true,
|
|
300
|
+
});
|
|
301
|
+
} else if (platform === "darwin") {
|
|
302
|
+
child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
303
|
+
} else {
|
|
304
|
+
child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
child.on("error", () => {
|
|
308
|
+
if (platform === "win32") {
|
|
309
|
+
spawn(
|
|
310
|
+
"powershell.exe",
|
|
311
|
+
["-NoProfile", "-Command", `Start-Process -FilePath '${url.replace(/'/g, "''")}'`],
|
|
312
|
+
{ detached: true, stdio: "ignore", windowsHide: true },
|
|
313
|
+
).unref();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Browser open failed - caller already has the URL via onLoginUrl callback
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
child.unref();
|
|
320
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
loadConfig,
|
|
4
|
+
saveConfig,
|
|
5
|
+
getApiUrl,
|
|
6
|
+
hasValidToken,
|
|
7
|
+
computeExpiresAt,
|
|
8
|
+
getConfigPath,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { apiRequest, buildQuery } from "./api.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register all data-mgr tools on the MCP server.
|
|
14
|
+
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
15
|
+
*/
|
|
16
|
+
export function registerTools(server) {
|
|
17
|
+
|
|
18
|
+
// Tool 1: analytics_query — wraps POST /mcp
|
|
19
|
+
server.tool(
|
|
20
|
+
"analytics_query",
|
|
21
|
+
"Query the data-mgr analytics API. Send a structured query with database, metric, parameters, and filters. Returns tabular results (columns + rows).",
|
|
22
|
+
{
|
|
23
|
+
db: z.string().optional().describe("Database group name (empty for default)"),
|
|
24
|
+
metric: z.string().describe("Metric or table name to query"),
|
|
25
|
+
params: z.record(z.any()).optional().describe("Query parameters (key-value pairs)"),
|
|
26
|
+
filters: z.record(z.string()).optional().describe("Filter conditions (key-value pairs)"),
|
|
27
|
+
limit: z.number().int().optional().describe("Max rows to return"),
|
|
28
|
+
},
|
|
29
|
+
async ({ db, metric, params, filters, limit }) => {
|
|
30
|
+
if (!hasValidToken()) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: "Error: Not logged in. Use the `check_auth` tool first, then run `login` if needed.",
|
|
35
|
+
}],
|
|
36
|
+
isError: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const body = { metric };
|
|
41
|
+
if (db) body.db = db;
|
|
42
|
+
if (params) body.params = params;
|
|
43
|
+
if (filters) body.filters = filters;
|
|
44
|
+
if (limit != null) body.limit = limit;
|
|
45
|
+
|
|
46
|
+
const data = await apiRequest("/mcp", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Format response as readable text
|
|
52
|
+
const lines = [
|
|
53
|
+
`Query: db=${db ?? "(default)"}, metric=${metric}`,
|
|
54
|
+
`Columns: ${data.columns?.map(c => c.name ?? c).join(", ") || "(none)"}`,
|
|
55
|
+
`Rows: ${data.total ?? data.rows?.length ?? 0} (showing ${data.rows?.length ?? 0})`,
|
|
56
|
+
`Duration: ${data.duration_ms ?? "?"}ms`,
|
|
57
|
+
];
|
|
58
|
+
if (data.message) lines.push(`Message: ${data.message}`);
|
|
59
|
+
|
|
60
|
+
// Table output
|
|
61
|
+
if (data.columns?.length && data.rows?.length) {
|
|
62
|
+
lines.push("");
|
|
63
|
+
const colNames = data.columns.map(c => c.name ?? c);
|
|
64
|
+
lines.push(colNames.join("\t"));
|
|
65
|
+
for (const row of data.rows) {
|
|
66
|
+
lines.push(colNames.map(k => row[k] ?? "").join("\t"));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: `API Error: ${err.message}` }],
|
|
74
|
+
isError: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Tool 2: api_request — generic authenticated API request
|
|
81
|
+
server.tool(
|
|
82
|
+
"api_request",
|
|
83
|
+
"Make a generic authenticated API request to the data-mgr backend. Supports GET, POST, PUT, DELETE with optional JSON body and query parameters.",
|
|
84
|
+
{
|
|
85
|
+
method: z.enum(["GET", "POST", "PUT", "DELETE"]).describe("HTTP method"),
|
|
86
|
+
path: z.string().describe("API path, e.g. /api/v1/datasets"),
|
|
87
|
+
body: z.record(z.any()).optional().describe("JSON request body (for POST/PUT)"),
|
|
88
|
+
query: z.record(z.string()).optional().describe("Query parameters (key-value pairs)"),
|
|
89
|
+
},
|
|
90
|
+
async ({ method, path, body, query }) => {
|
|
91
|
+
if (!hasValidToken()) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{
|
|
94
|
+
type: "text",
|
|
95
|
+
text: "Error: Not logged in. Use `check_auth` then `login`.",
|
|
96
|
+
}],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const fullPath = `${path}${buildQuery(query)}`;
|
|
102
|
+
const init = { method };
|
|
103
|
+
if (body) init.body = JSON.stringify(body);
|
|
104
|
+
|
|
105
|
+
const data = await apiRequest(fullPath, init);
|
|
106
|
+
return {
|
|
107
|
+
content: [{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
|
|
110
|
+
}],
|
|
111
|
+
};
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: `API Error: ${err.message}` }],
|
|
115
|
+
isError: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Tool 3: check_auth — check login status
|
|
122
|
+
server.tool(
|
|
123
|
+
"check_auth",
|
|
124
|
+
"Check whether the user is logged in to data-mgr and the JWT token is valid. Returns login status, username, token expiry, and API URL.",
|
|
125
|
+
{},
|
|
126
|
+
async () => {
|
|
127
|
+
const cfg = loadConfig();
|
|
128
|
+
const apiUrl = getApiUrl();
|
|
129
|
+
const loggedIn = hasValidToken();
|
|
130
|
+
|
|
131
|
+
const expiresAt =
|
|
132
|
+
cfg.expiresAt ??
|
|
133
|
+
(cfg.expiresIn && cfg.loggedInAt
|
|
134
|
+
? computeExpiresAt(cfg.expiresIn, new Date(cfg.loggedInAt))
|
|
135
|
+
: null);
|
|
136
|
+
|
|
137
|
+
const info = {
|
|
138
|
+
loggedIn,
|
|
139
|
+
apiUrl,
|
|
140
|
+
username: cfg.username ?? null,
|
|
141
|
+
userId: cfg.userId ?? null,
|
|
142
|
+
tenantId: cfg.tenantId ?? null,
|
|
143
|
+
expiresAt,
|
|
144
|
+
configPath: getConfigPath(),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (!loggedIn) {
|
|
148
|
+
return {
|
|
149
|
+
content: [{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: `Not logged in.\nAPI: ${apiUrl}\nRun the \`login\` tool or manually: data-mgr login\n\n${JSON.stringify(info, null, 2)}`,
|
|
152
|
+
}],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
content: [{
|
|
158
|
+
type: "text",
|
|
159
|
+
text: `Logged in as: ${cfg.username ?? "(unknown)"}\n\n${JSON.stringify(info, null, 2)}`,
|
|
160
|
+
}],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Tool 4: login — trigger browser login flow
|
|
166
|
+
server.tool(
|
|
167
|
+
"login",
|
|
168
|
+
"Trigger the data-mgr browser login flow. Opens a browser window for the user to enter credentials. Returns login result on success. If browser cannot be opened, returns the URL for manual visit.",
|
|
169
|
+
{
|
|
170
|
+
openBrowser: z.boolean().optional().describe("Whether to open browser automatically (default true)"),
|
|
171
|
+
},
|
|
172
|
+
async ({ openBrowser }) => {
|
|
173
|
+
try {
|
|
174
|
+
const { runBrowserLogin } = await import("./login-server.js");
|
|
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
|
+
});
|
|
183
|
+
|
|
184
|
+
const expiresAt = computeExpiresAt(result.expiresIn, new Date(result.loggedInAt));
|
|
185
|
+
|
|
186
|
+
saveConfig({
|
|
187
|
+
token: result.token,
|
|
188
|
+
username: result.username,
|
|
189
|
+
userId: result.userId,
|
|
190
|
+
tenantId: result.tenantId,
|
|
191
|
+
expiresIn: result.expiresIn,
|
|
192
|
+
expiresAt,
|
|
193
|
+
loggedInAt: result.loggedInAt,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const lines = [
|
|
197
|
+
`Logged in as: ${result.username ?? "(unknown)"}`,
|
|
198
|
+
`User ID: ${result.userId}`,
|
|
199
|
+
`Tenant ID: ${result.tenantId}`,
|
|
200
|
+
`Token saved to: ${getConfigPath()}`,
|
|
201
|
+
`Expires: ${expiresAt ?? "(unknown)"}`,
|
|
202
|
+
];
|
|
203
|
+
if (loginUrl) lines.unshift(`Login URL: ${loginUrl}`);
|
|
204
|
+
|
|
205
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: `Login failed: ${err.message}` }],
|
|
209
|
+
isError: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wsh19991219/mcp-server",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP server for Data Manager API — stdio transport, JWT auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/tools.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"data-mgr-mcp": "bin/data-mgr-mcp.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib",
|
|
13
|
+
"skills",
|
|
14
|
+
"SKILL.md",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node bin/data-mgr-mcp.js"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
## name: daily-channel-report
|
|
4
|
+
description: 渠道交易日报:按固定口径查昨日数据、输出【最终结论】
|
|
5
|
+
triggers:
|
|
6
|
+
- 日报
|
|
7
|
+
- 每日报表
|
|
8
|
+
- 定时报表
|
|
9
|
+
- 昨日交易
|
|
10
|
+
- daily report
|
|
11
|
+
- 交易汇总
|
|
12
|
+
|
|
13
|
+
## 适用场景
|
|
14
|
+
|
|
15
|
+
用户或定时任务要求生成**某一渠道、某一自然日**的交易汇总(笔数、金额、趋势等)。
|
|
16
|
+
|
|
17
|
+
## 固定流程(必须按序)
|
|
18
|
+
|
|
19
|
+
### 1. 确认参数
|
|
20
|
+
|
|
21
|
+
- 渠道:`channel_id=1` Photon · `2` Interlace · `3` PingPong
|
|
22
|
+
- 日期:默认**昨天**(自然日)
|
|
23
|
+
- 数据库 group:默认 `default`,用户指定则用对应 group
|
|
24
|
+
|
|
25
|
+
定时任务已写明参数时勿重复追问。
|
|
26
|
+
|
|
27
|
+
### 2. 鉴权
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
check_auth → 未登录则 login
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 3. 定位表
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
api_request: GET /api/v1/analytics/schema?q=<渠道关键词>&db=<group>
|
|
37
|
+
api_request: GET /api/v1/analytics/schema?tables=<目标表>&db=<group>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
渠道表名参考:
|
|
41
|
+
|
|
42
|
+
- channel_id=1 → `photonpay_card_transaction`
|
|
43
|
+
- channel_id=2 → `interlace_card_transaction`
|
|
44
|
+
- channel_id=3 → `pingpong_card_transaction`
|
|
45
|
+
|
|
46
|
+
### 4. 查数
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
analytics_query:
|
|
50
|
+
metric: "<指标>" # 或用 api_request SQL
|
|
51
|
+
db: "<group>"
|
|
52
|
+
filters: { "channel_id": "<N>", "date": "<YYYY-MM-DD>" }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
或自定义 SQL:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
api_request: POST /api/v1/analytics/sql
|
|
59
|
+
body:
|
|
60
|
+
db: "<group>"
|
|
61
|
+
sql: "SELECT COUNT(*) AS cnt, SUM(<主金额字段>) AS total FROM <表> WHERE channel_id = <N> AND DATE(create_time) = '<日期>'"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**禁止未调用工具就写具体数字。**
|
|
65
|
+
|
|
66
|
+
### 5. 交付
|
|
67
|
+
|
|
68
|
+
用 **【最终结论】** 标题,包含:
|
|
69
|
+
|
|
70
|
+
- 日期、渠道、数据库 group
|
|
71
|
+
- 笔数、金额(注明 USD 口径字段名)
|
|
72
|
+
- 1~2 条洞察(趋势、异常、占比)
|
|
73
|
+
- 如有空值/异常数据,注明条数
|
|
74
|
+
|
|
75
|
+
## 金额口径
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
| 渠道 | 主金额字段 |
|
|
79
|
+
| ----------- | ----------------------------- |
|
|
80
|
+
| 1 photon | `txn_principal_change_amount` |
|
|
81
|
+
| 2 interlace | `transaction_amount` |
|
|
82
|
+
| 3 pingpong | `billing_amount` |
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## 日期占位
|
|
86
|
+
|
|
87
|
+
用户或外部编排可在提示词中使用 `{{today}}`、`{{yesterday}}`;由调用方在传入前展开。
|
|
88
|
+
|
|
89
|
+
## 禁止
|
|
90
|
+
|
|
91
|
+
- 索要 JWT / token
|
|
92
|
+
- 编造未出现在 tool_result 中的数字
|
|
93
|
+
- 用错主金额字段(见上表)
|
|
94
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: data-analysis
|
|
3
|
+
description: 数据分析师流程:六步检查清单 + 三阶段取数;含渠道术语与 USD 金额口径
|
|
4
|
+
triggers:
|
|
5
|
+
- 数据分析
|
|
6
|
+
- 对账
|
|
7
|
+
- 报表
|
|
8
|
+
- 漏斗
|
|
9
|
+
- 留存
|
|
10
|
+
- 流失
|
|
11
|
+
- 渠道对比
|
|
12
|
+
- 趋势
|
|
13
|
+
- 同比
|
|
14
|
+
- 环比
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 适用场景
|
|
18
|
+
|
|
19
|
+
查业务数据、对账、报表、指标含义、漏斗/留存类分析。简单查数请用 `data-query` Skill。
|
|
20
|
+
|
|
21
|
+
## 任务分级(先判断再动手)
|
|
22
|
+
|
|
23
|
+
| 类型 | 识别 | 执行方式 |
|
|
24
|
+
|------|------|----------|
|
|
25
|
+
| **A 单点查数** | 一句问金额/笔数/某日统计 | 仅三阶段 |
|
|
26
|
+
| **B 对账/渠道对比** | 两源差异、多 channel_id | 三阶段 + 对账模板 |
|
|
27
|
+
| **C 探索/漏斗/报告** | 留存、流失、多维对比 | 三阶段 + 六步中间产物 |
|
|
28
|
+
|
|
29
|
+
## 六步检查清单
|
|
30
|
+
|
|
31
|
+
| 步 | 目标 | 手段 | 交付物 |
|
|
32
|
+
|:--:|------|------|--------|
|
|
33
|
+
| 1 | **明确目标** | 澄清或复述需求 | 分析对象、时间范围、指标定义 |
|
|
34
|
+
| 2 | 数据收集 | `api_request` catalog → schema → query/sql | 用了哪些表/指标;0 行须说明 |
|
|
35
|
+
| 3 | 数据处理 | SQL WHERE/JOIN/去重/口径过滤 | 过滤条件、去重规则、主金额字段名 |
|
|
36
|
+
| 4 | 探索 EDA | 分组、趋势、同比 | 至少 2 个维度 |
|
|
37
|
+
| 5 | 建模(可选) | SQL 聚合或可解释对比 | 无模型则跳过 |
|
|
38
|
+
| 6 | **呈现报告** | 结构化中文结论 | 现状 → 洞察 → 建议 |
|
|
39
|
+
|
|
40
|
+
## 连贯三阶段执行
|
|
41
|
+
|
|
42
|
+
### 阶段 0:澄清(缺参时)
|
|
43
|
+
|
|
44
|
+
时间窗、平台/`channel_id`、`db=` 部署库等缺失时,**必须**列出 2~4 个候选让用户选择。禁止猜默认值。
|
|
45
|
+
|
|
46
|
+
### 阶段 1:定位(数据模型)
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
check_auth → login(如需)
|
|
50
|
+
api_request: GET /api/v1/analytics/catalog # 看可用指标和数据库
|
|
51
|
+
api_request: GET /api/v1/analytics/schema?q=<关键词> # 搜表
|
|
52
|
+
api_request: GET /api/v1/analytics/schema?tables=<表名> # 看列名与类型
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
确认渠道层级与主金额列。
|
|
56
|
+
|
|
57
|
+
### 阶段 2:画像(样例 + 汇总)
|
|
58
|
+
|
|
59
|
+
在写业务结论 SQL **之前**必须:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
api_request: POST /api/v1/analytics/sql
|
|
63
|
+
body: { "sql": "SELECT 关键列 FROM 表 LIMIT 10", "db": "<group>" } -- 样例
|
|
64
|
+
|
|
65
|
+
api_request: POST /api/v1/analytics/sql
|
|
66
|
+
body: { "sql": "SELECT COUNT(*), AVG(x), MIN(x), MAX(x) FROM 表", "db": "<group>" } -- 汇总
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
先列出各列类型印象与汇总表,再进入分析。
|
|
70
|
+
|
|
71
|
+
### 阶段 3:分析 + 交付
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
analytics_query: { metric: "...", db: "...", params: {...}, filters: {...} }
|
|
75
|
+
-- 或 --
|
|
76
|
+
api_request: POST /api/v1/analytics/sql
|
|
77
|
+
body: { "sql": "SELECT ...", "db": "<group>", "limit": 100 }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
交付格式:**现状 → 洞察 → 建议**,附局限说明。
|
|
81
|
+
|
|
82
|
+
## 任务模板
|
|
83
|
+
|
|
84
|
+
### 对账(类型 B)
|
|
85
|
+
|
|
86
|
+
1. 复述:对账双方、时间窗、channel_id、主金额字段
|
|
87
|
+
2. 分别查询两边汇总,SQL 注释写对齐键
|
|
88
|
+
3. 交付:**差额、可能原因、未对齐条数**
|
|
89
|
+
|
|
90
|
+
### 渠道对比(类型 B)
|
|
91
|
+
|
|
92
|
+
1. 每个 channel_id 独立 SELECT + 正确主金额列
|
|
93
|
+
2. EDA:至少「合计 + 按日或按状态」两维
|
|
94
|
+
3. 交付:表格对比 + 占比
|
|
95
|
+
|
|
96
|
+
### 漏斗/留存(类型 C)
|
|
97
|
+
|
|
98
|
+
1. 明确漏斗步骤或留存定义
|
|
99
|
+
2. `api_request` schema 找行为/表 → SQL 分步计数
|
|
100
|
+
3. 交付:各步转化率 + 流失最大节点 + 建议
|
|
101
|
+
|
|
102
|
+
## 渠道术语
|
|
103
|
+
|
|
104
|
+
| 层级 | 取值 |
|
|
105
|
+
|------|------|
|
|
106
|
+
| 部署库 | `default` 主库, `prod_vn` 越南, `prod_vero` Vero, `prod_zenia` Zenia, `prod_jtpay` JT Pay |
|
|
107
|
+
| 渠道编号 | 1=photon, 2=interlace, 3=pingpong |
|
|
108
|
+
|
|
109
|
+
## 金额口径(USD)
|
|
110
|
+
|
|
111
|
+
| 渠道 | 主金额字段 |
|
|
112
|
+
|:----:|------------|
|
|
113
|
+
| 1 | `txn_principal_change_amount` |
|
|
114
|
+
| 2 | `transaction_amount` |
|
|
115
|
+
| 3 | `billing_amount` |
|
|
116
|
+
|
|
117
|
+
## 禁止
|
|
118
|
+
|
|
119
|
+
- 未调用工具就给出具体数字
|
|
120
|
+
- 编造未出现在 tool_result 中的数字
|
|
121
|
+
- 索要 JWT / token
|
|
122
|
+
- 为同一问题重复搜 schema
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: data-query
|
|
3
|
+
description: 数据查询:查指标、查数据、执行预定义 metric 查询或自定义只读 SQL
|
|
4
|
+
triggers:
|
|
5
|
+
- 数据查询
|
|
6
|
+
- 查数据
|
|
7
|
+
- 指标
|
|
8
|
+
- 统计
|
|
9
|
+
- 多少笔
|
|
10
|
+
- 多少钱
|
|
11
|
+
- 金额
|
|
12
|
+
- 笔数
|
|
13
|
+
- sql
|
|
14
|
+
- SQL
|
|
15
|
+
- query
|
|
16
|
+
- analytics
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 适用场景
|
|
20
|
+
|
|
21
|
+
用户问业务数据:金额、笔数、统计、指标值。本 Skill 覆盖两种取数方式:预定义指标查询 和 自定义 SQL。
|
|
22
|
+
|
|
23
|
+
## 固定流程
|
|
24
|
+
|
|
25
|
+
### 1. 鉴权检查
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
check_auth → 未登录则 login
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. 定位数据(先搞清楚查什么)
|
|
32
|
+
|
|
33
|
+
**查看可用指标:**
|
|
34
|
+
```
|
|
35
|
+
api_request: GET /api/v1/analytics/catalog
|
|
36
|
+
```
|
|
37
|
+
返回 `metrics`(可用指标列表)和 `databases`(可用数据库 group)。
|
|
38
|
+
|
|
39
|
+
**查看表结构(如需 SQL):**
|
|
40
|
+
```
|
|
41
|
+
api_request: GET /api/v1/analytics/schema?q=<关键词>&db=<group>
|
|
42
|
+
api_request: GET /api/v1/analytics/schema?tables=<表名>&db=<group>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 3. 取数(二选一)
|
|
46
|
+
|
|
47
|
+
**方式 A — 预定义指标查询:**
|
|
48
|
+
```
|
|
49
|
+
analytics_query:
|
|
50
|
+
metric: "<指标ID>"
|
|
51
|
+
db: "<group>" # 可选
|
|
52
|
+
params: { ... } # 可选,指标参数
|
|
53
|
+
filters: { ... } # 可选,过滤条件
|
|
54
|
+
limit: 100 # 可选
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**方式 B — 自定义 SQL:**
|
|
58
|
+
```
|
|
59
|
+
api_request:
|
|
60
|
+
method: POST
|
|
61
|
+
path: /api/v1/analytics/sql
|
|
62
|
+
body:
|
|
63
|
+
db: "<group>" # 可选
|
|
64
|
+
sql: "SELECT ..."
|
|
65
|
+
limit: 100 # 可选,默认 100,最大 500
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
SQL 限制:
|
|
69
|
+
- 仅允许 SELECT / WITH(只读)
|
|
70
|
+
- 禁止 DML(INSERT/UPDATE/DELETE)
|
|
71
|
+
- 禁止注释(`--`、`/* */`)
|
|
72
|
+
- 禁止多语句(`;` 分隔)
|
|
73
|
+
- 最大 16KB
|
|
74
|
+
|
|
75
|
+
### 4. 交付
|
|
76
|
+
|
|
77
|
+
- 数字必须来自 tool_result,禁止编造
|
|
78
|
+
- 0 行结果须说明「未查到数据」
|
|
79
|
+
- 标注使用的 db group 和主金额字段名
|
|
80
|
+
- 敏感数据(手机/身份证/邮箱等)会被自动脱敏
|
|
81
|
+
|
|
82
|
+
## 渠道术语
|
|
83
|
+
|
|
84
|
+
| 层级 | 含义 | 取值 |
|
|
85
|
+
|------|------|------|
|
|
86
|
+
| 部署/区域 | 独立数据库实例 | `db=default` 主库;`db=prod_vn` 越南;`db=prod_vero` Vero;`db=prod_zenia` Zenia;`db=prod_jtpay` JT Pay |
|
|
87
|
+
| 支付平台 | 第三方品牌 | photon, pingpong, interlace |
|
|
88
|
+
| 渠道编号 | channel_id | 1=photon, 2=interlace, 3=pingpong |
|
|
89
|
+
|
|
90
|
+
## 金额统计口径(USD)
|
|
91
|
+
|
|
92
|
+
| 渠道 | 主金额字段 | 禁止替代 |
|
|
93
|
+
|:----:|------------|----------|
|
|
94
|
+
| 1 photon | `txn_principal_change_amount` | `transaction_amount` / `amount` / `arrival_amount` |
|
|
95
|
+
| 2 interlace | `transaction_amount` | `billing_amount` |
|
|
96
|
+
| 3 pingpong | `billing_amount` | `transaction_amount` |
|
|
97
|
+
|
|
98
|
+
## 注意
|
|
99
|
+
|
|
100
|
+
- 未调用工具就给出具体数字是**严格禁止**的
|
|
101
|
+
- 主字段为空时报告空值条数,勿静默换列凑数
|
|
102
|
+
- 跨渠道汇总时分渠道取主金额字段再相加,禁止三渠道共用同一列名
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: schema-explorer
|
|
3
|
+
description: 数据库表结构探索:模糊搜表名、查看列详情、了解数据模型
|
|
4
|
+
triggers:
|
|
5
|
+
- 查表
|
|
6
|
+
- 表结构
|
|
7
|
+
- 搜表
|
|
8
|
+
- 有哪些表
|
|
9
|
+
- 字段
|
|
10
|
+
- 列名
|
|
11
|
+
- schema
|
|
12
|
+
- tables
|
|
13
|
+
- 数据库结构
|
|
14
|
+
- INFORMATION_SCHEMA
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 适用场景
|
|
18
|
+
|
|
19
|
+
用户想了解数据库有哪些表、某张表的字段结构、按关键词搜索相关表。
|
|
20
|
+
|
|
21
|
+
## 固定流程
|
|
22
|
+
|
|
23
|
+
### 1. 鉴权检查
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
check_auth → 未登录则 login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. 搜索表(模糊查找)
|
|
30
|
+
|
|
31
|
+
用 `api_request` 调 REST 端点:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
api_request: GET /api/v1/analytics/schema?q=<关键词>&db=<group>&limit=50
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
参数说明:
|
|
38
|
+
- `q` — 模糊搜表名/表注释(最多 64 字符,禁 `% _ ;` 等特殊字符)
|
|
39
|
+
- `db` — 数据库 group,空则 default(可选值:`prod_vn`, `prod_vero`, `prod_zenia`, `prod_jtpay`)
|
|
40
|
+
- `limit` — 最多返回表数,默认 50,最大 100
|
|
41
|
+
|
|
42
|
+
### 3. 查看表列结构(精确查找)
|
|
43
|
+
|
|
44
|
+
搜到目标表后,用精确表名查列:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
api_request: GET /api/v1/analytics/schema?tables=<表名1>,<表名2>&db=<group>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
返回每张表的:列名、类型、是否可空、默认值、注释、键类型。
|
|
51
|
+
|
|
52
|
+
### 4. 交付格式
|
|
53
|
+
|
|
54
|
+
- 列出表名 + 注释 + 预估行数
|
|
55
|
+
- 对关键表列出列名、类型、注释
|
|
56
|
+
- 标注主键和敏感列
|
|
57
|
+
- 如发现渠道相关表,提醒用户 channel_id 含义:1=photon, 2=interlace, 3=pingpong
|
|
58
|
+
|
|
59
|
+
## 注意
|
|
60
|
+
|
|
61
|
+
- `q` 参数禁止 `% _ ;` 等特殊字符
|
|
62
|
+
- 单次搜索最多返回 100 张表
|
|
63
|
+
- 结果可能有缓存延迟(schema 缓存周期取决于服务端配置)
|