@lightcone-ai/daemon 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp-servers/mysql/core.js +270 -0
- package/mcp-servers/mysql/index.js +79 -151
- package/mcp-servers/mysql/manifest.json +8 -6
- package/package.json +1 -1
- package/src/chat-bridge.js +42 -0
- package/src/mcp-config.js +2 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LIMIT = 200;
|
|
4
|
+
export const MAX_LIMIT = 1000;
|
|
5
|
+
|
|
6
|
+
const READ_QUERY_PREFIX = /^(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|WITH)\b/i;
|
|
7
|
+
const REQUIRED_DB_KEYS = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
|
|
8
|
+
|
|
9
|
+
function trimTrailingSemicolons(sql) {
|
|
10
|
+
return sql.replace(/;+\s*$/u, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeSql(rawSql) {
|
|
14
|
+
if (typeof rawSql !== 'string') return '';
|
|
15
|
+
const trimmed = rawSql.trim();
|
|
16
|
+
if (!trimmed) return '';
|
|
17
|
+
return trimTrailingSemicolons(trimmed);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeLimit(limit) {
|
|
21
|
+
const numeric = Number(limit);
|
|
22
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_LIMIT;
|
|
23
|
+
return Math.min(Math.trunc(numeric), MAX_LIMIT);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isReadQuery(sql) {
|
|
27
|
+
return READ_QUERY_PREFIX.test(sql);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hasLimitClause(sql) {
|
|
31
|
+
return /\bLIMIT\b/i.test(sql);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function prepareSql(sql, limit) {
|
|
35
|
+
const normalizedSql = normalizeSql(sql);
|
|
36
|
+
if (!normalizedSql) throw new Error('sql must be a non-empty string');
|
|
37
|
+
|
|
38
|
+
const effectiveLimit = normalizeLimit(limit);
|
|
39
|
+
if (!isReadQuery(normalizedSql) || hasLimitClause(normalizedSql)) {
|
|
40
|
+
return {
|
|
41
|
+
sql: normalizedSql,
|
|
42
|
+
limit: effectiveLimit,
|
|
43
|
+
limitParams: [],
|
|
44
|
+
limitInjected: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
sql: `${normalizedSql} LIMIT ?`,
|
|
50
|
+
limit: effectiveLimit,
|
|
51
|
+
limitParams: [effectiveLimit],
|
|
52
|
+
limitInjected: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRequiredKey(envVars, key) {
|
|
57
|
+
const raw = envVars?.[key];
|
|
58
|
+
if (raw == null || String(raw).trim() === '') return null;
|
|
59
|
+
return String(raw).trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseDbConfig(envVars) {
|
|
63
|
+
const missing = [];
|
|
64
|
+
const values = {};
|
|
65
|
+
for (const key of REQUIRED_DB_KEYS) {
|
|
66
|
+
const value = readRequiredKey(envVars, key);
|
|
67
|
+
if (value == null) {
|
|
68
|
+
missing.push(key);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
values[key] = value;
|
|
72
|
+
}
|
|
73
|
+
if (missing.length > 0) {
|
|
74
|
+
throw new Error(`credential payload missing required DB fields: ${missing.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const port = Number(values.DB_PORT);
|
|
78
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
79
|
+
throw new Error('credential payload has invalid DB_PORT');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
host: values.DB_HOST,
|
|
84
|
+
port,
|
|
85
|
+
user: values.DB_USER,
|
|
86
|
+
password: values.DB_PASSWORD,
|
|
87
|
+
database: values.DB_NAME,
|
|
88
|
+
waitForConnections: true,
|
|
89
|
+
connectionLimit: 3,
|
|
90
|
+
queueLimit: 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fingerprintDbConfig(dbConfig) {
|
|
95
|
+
return createHash('sha256')
|
|
96
|
+
.update(JSON.stringify([
|
|
97
|
+
dbConfig.host,
|
|
98
|
+
dbConfig.port,
|
|
99
|
+
dbConfig.user,
|
|
100
|
+
dbConfig.password,
|
|
101
|
+
dbConfig.database,
|
|
102
|
+
]))
|
|
103
|
+
.digest('hex');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createDataSourcePoolRegistry({ createPool, logger = () => {} } = {}) {
|
|
107
|
+
if (typeof createPool !== 'function') {
|
|
108
|
+
throw new Error('createPool function is required');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pools = new Map();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
getOrCreate(dataSourceId, dbConfig) {
|
|
115
|
+
const id = String(dataSourceId ?? '').trim();
|
|
116
|
+
if (!id) throw new Error('data_source_id must be a non-empty string');
|
|
117
|
+
const nextFingerprint = fingerprintDbConfig(dbConfig);
|
|
118
|
+
const existing = pools.get(id);
|
|
119
|
+
|
|
120
|
+
if (existing && existing.fingerprint === nextFingerprint) {
|
|
121
|
+
return existing.pool;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (existing) {
|
|
125
|
+
Promise.resolve(existing.pool.end())
|
|
126
|
+
.catch((error) => logger(`[mysql-mcp] failed to close previous pool for data_source_id=${id}: ${error.message}`));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pool = createPool(dbConfig);
|
|
130
|
+
pools.set(id, { fingerprint: nextFingerprint, pool });
|
|
131
|
+
return pool;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async closeAll() {
|
|
135
|
+
const closing = [];
|
|
136
|
+
for (const { pool } of pools.values()) {
|
|
137
|
+
closing.push(
|
|
138
|
+
Promise.resolve(pool.end()).catch((error) => {
|
|
139
|
+
logger(`[mysql-mcp] failed to close pool: ${error.message}`);
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
pools.clear();
|
|
144
|
+
await Promise.all(closing);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
size() {
|
|
148
|
+
return pools.size;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeServerUrl(serverUrl) {
|
|
154
|
+
return String(serverUrl ?? '').trim().replace(/\/+$/u, '');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createCredentialBrokerClient({
|
|
158
|
+
fetchFn = globalThis.fetch,
|
|
159
|
+
serverUrl = '',
|
|
160
|
+
machineApiKey = '',
|
|
161
|
+
agentId = '',
|
|
162
|
+
workspaceId = '',
|
|
163
|
+
bundleId = '',
|
|
164
|
+
} = {}) {
|
|
165
|
+
if (typeof fetchFn !== 'function') {
|
|
166
|
+
throw new Error('fetch function is required');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const normalizedServerUrl = normalizeServerUrl(serverUrl);
|
|
170
|
+
const normalizedMachineApiKey = String(machineApiKey ?? '').trim();
|
|
171
|
+
const normalizedAgentId = String(agentId ?? '').trim();
|
|
172
|
+
const normalizedWorkspaceId = String(workspaceId ?? '').trim();
|
|
173
|
+
const normalizedBundleId = String(bundleId ?? '').trim();
|
|
174
|
+
|
|
175
|
+
return async function fetchCredentialEnvVars(dataSourceId) {
|
|
176
|
+
const normalizedDataSourceId = String(dataSourceId ?? '').trim();
|
|
177
|
+
if (!normalizedDataSourceId) {
|
|
178
|
+
throw new Error('data_source_id is required');
|
|
179
|
+
}
|
|
180
|
+
if (!normalizedServerUrl || !normalizedMachineApiKey || !normalizedAgentId) {
|
|
181
|
+
throw new Error('mysql MCP missing SERVER_URL/MACHINE_API_KEY/AGENT_ID runtime context');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const response = await fetchFn(`${normalizedServerUrl}/governance/credential-broker`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
'Authorization': `Bearer ${normalizedMachineApiKey}`,
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
agent_id: normalizedAgentId,
|
|
192
|
+
workspace_id: normalizedWorkspaceId || null,
|
|
193
|
+
required_credentials: [normalizedDataSourceId],
|
|
194
|
+
bundle_id: normalizedBundleId || null,
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
let payload = null;
|
|
199
|
+
try {
|
|
200
|
+
payload = await response.json();
|
|
201
|
+
} catch {
|
|
202
|
+
payload = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
const reason = typeof payload?.error === 'string'
|
|
207
|
+
? payload.error
|
|
208
|
+
: `status_${response.status}`;
|
|
209
|
+
throw new Error(`credential_broker_failed:${reason}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const envVars = payload?.env_vars;
|
|
213
|
+
if (!envVars || typeof envVars !== 'object' || Array.isArray(envVars)) {
|
|
214
|
+
throw new Error('credential_broker_invalid_response');
|
|
215
|
+
}
|
|
216
|
+
return envVars;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function executeSql({
|
|
221
|
+
dataSourceId,
|
|
222
|
+
sql,
|
|
223
|
+
params = [],
|
|
224
|
+
limit,
|
|
225
|
+
fetchCredentialEnvVars,
|
|
226
|
+
poolRegistry,
|
|
227
|
+
} = {}) {
|
|
228
|
+
const normalizedDataSourceId = String(dataSourceId ?? '').trim();
|
|
229
|
+
if (!normalizedDataSourceId) {
|
|
230
|
+
throw new Error('data_source_id is required');
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(params)) {
|
|
233
|
+
throw new Error('params must be an array');
|
|
234
|
+
}
|
|
235
|
+
if (typeof fetchCredentialEnvVars !== 'function') {
|
|
236
|
+
throw new Error('fetchCredentialEnvVars function is required');
|
|
237
|
+
}
|
|
238
|
+
if (!poolRegistry || typeof poolRegistry.getOrCreate !== 'function') {
|
|
239
|
+
throw new Error('poolRegistry.getOrCreate is required');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const prepared = prepareSql(sql, limit);
|
|
243
|
+
const credentialEnv = await fetchCredentialEnvVars(normalizedDataSourceId);
|
|
244
|
+
const dbConfig = parseDbConfig(credentialEnv);
|
|
245
|
+
const pool = poolRegistry.getOrCreate(normalizedDataSourceId, dbConfig);
|
|
246
|
+
|
|
247
|
+
const queryParams = [...params, ...prepared.limitParams];
|
|
248
|
+
const [result] = await pool.query(prepared.sql, queryParams);
|
|
249
|
+
|
|
250
|
+
if (Array.isArray(result)) {
|
|
251
|
+
const rows = result.length > prepared.limit
|
|
252
|
+
? result.slice(0, prepared.limit)
|
|
253
|
+
: result;
|
|
254
|
+
return {
|
|
255
|
+
data_source_id: normalizedDataSourceId,
|
|
256
|
+
row_count: rows.length,
|
|
257
|
+
limit: prepared.limit,
|
|
258
|
+
limit_applied: prepared.limitInjected || rows.length < result.length,
|
|
259
|
+
rows,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
data_source_id: normalizedDataSourceId,
|
|
265
|
+
affected_rows: Number(result?.affectedRows ?? 0),
|
|
266
|
+
changed_rows: Number(result?.changedRows ?? 0),
|
|
267
|
+
insert_id: result?.insertId ?? null,
|
|
268
|
+
warning_status: Number(result?.warningStatus ?? 0),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -3,161 +3,89 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import mysql from 'mysql2/promise';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
import { pathToFileURL } from 'url';
|
|
7
|
+
import {
|
|
8
|
+
createCredentialBrokerClient,
|
|
9
|
+
createDataSourcePoolRegistry,
|
|
10
|
+
executeSql,
|
|
11
|
+
} from './core.js';
|
|
12
|
+
|
|
13
|
+
const TOOL_SCHEMA = {
|
|
14
|
+
data_source_id: z.string().describe('数据源 ID(当前实现映射为 credential_id)'),
|
|
15
|
+
sql: z.string().describe('要执行的 SQL。支持参数占位符 ?'),
|
|
16
|
+
params: z.array(z.any()).optional().describe('SQL 参数数组(按顺序对应 ? 占位符)'),
|
|
17
|
+
limit: z.number().int().positive().optional().describe('结果行数上限。默认 200,最大 1000'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isExecutedDirectly(metaUrl) {
|
|
21
|
+
const entry = process.argv[1];
|
|
22
|
+
if (!entry) return false;
|
|
23
|
+
try {
|
|
24
|
+
return pathToFileURL(entry).href === metaUrl;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
11
27
|
}
|
|
12
|
-
return value;
|
|
13
28
|
}
|
|
14
29
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
params.push(max);
|
|
60
|
-
const sql = `
|
|
61
|
-
SELECT job_id, job_title, company_name, salary, work_location, job_type, degree, tags, publish_date
|
|
62
|
-
FROM job_details
|
|
63
|
-
WHERE ${conditions.join(' AND ')}
|
|
64
|
-
ORDER BY created_at DESC
|
|
65
|
-
LIMIT ?
|
|
66
|
-
`;
|
|
67
|
-
|
|
68
|
-
console.error(`[mysql-mcp] search_jobs: ${sql.replace(/\s+/g, ' ').trim()} | params: ${JSON.stringify(params)}`);
|
|
69
|
-
const [rows] = await pool.query(sql, params);
|
|
70
|
-
if (rows.length === 0) {
|
|
71
|
-
return { content: [{ type: 'text', text: '未找到匹配的职位。' }] };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const text = rows.map(r => [
|
|
75
|
-
`job_id: ${r.job_id}`,
|
|
76
|
-
`职位: ${r.job_title} @ ${r.company_name}`,
|
|
77
|
-
`类型: ${r.job_type ?? '-'} 地点: ${r.work_location ?? '-'} 学历: ${r.degree ?? '-'}`,
|
|
78
|
-
`薪资: ${r.salary || '未披露'}`,
|
|
79
|
-
`标签: ${(() => { try { return r.tags ? JSON.parse(r.tags).join(', ') : '-'; } catch { return r.tags ?? '-'; } })()}`,
|
|
80
|
-
`发布日期: ${r.publish_date ?? '-'}`,
|
|
81
|
-
].join('\n')).join('\n\n---\n\n');
|
|
82
|
-
|
|
83
|
-
return { content: [{ type: 'text', text: `共 ${rows.length} 条结果:\n\n${text}` }] };
|
|
84
|
-
}
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
// ── get_job_detail ────────────────────────────────────────────────────────────
|
|
88
|
-
server.tool('get_job_detail',
|
|
89
|
-
'根据 job_id 获取职位完整详情,包括职位描述全文。用于写手生成文案前获取完整信息。',
|
|
90
|
-
{
|
|
91
|
-
job_id: z.string().describe('职位 ID(从 search_jobs 结果中获取)'),
|
|
92
|
-
},
|
|
93
|
-
async ({ job_id }) => {
|
|
94
|
-
const detailSql = `SELECT jd.*, c.industry, c.scale, c.type as company_type, c.logo_url FROM job_details jd LEFT JOIN companies c ON jd.company_id = c.company_id WHERE jd.job_id = ?`;
|
|
95
|
-
console.error(`[mysql-mcp] get_job_detail: ${detailSql} | params: ${JSON.stringify([job_id])}`);
|
|
96
|
-
const [rows] = await pool.query(detailSql, [job_id]);
|
|
97
|
-
|
|
98
|
-
if (rows.length === 0) {
|
|
99
|
-
return { content: [{ type: 'text', text: `未找到 job_id=${job_id} 的职位。` }] };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const r = rows[0];
|
|
103
|
-
const tags = (() => { try { return r.tags ? JSON.parse(r.tags) : []; } catch { return []; } })();
|
|
104
|
-
const text = [
|
|
105
|
-
`【职位详情】`,
|
|
106
|
-
`职位名称: ${r.job_title}`,
|
|
107
|
-
`公司: ${r.company_name}${r.industry ? `(${r.industry})` : ''}`,
|
|
108
|
-
`公司规模: ${r.scale ?? '-'} 公司类型: ${r.company_type ?? '-'}`,
|
|
109
|
-
`薪资: ${r.salary || '未披露'}`,
|
|
110
|
-
`工作地点: ${r.work_location ?? '-'}`,
|
|
111
|
-
`职位类型: ${r.job_type ?? '-'}`,
|
|
112
|
-
`学历要求: ${r.degree ?? '-'}`,
|
|
113
|
-
`技能标签: ${tags.join(', ') || '-'}`,
|
|
114
|
-
`发布日期: ${r.publish_date ?? '-'}`,
|
|
115
|
-
`截止日期: ${r.close_date ?? '-'}`,
|
|
116
|
-
`原始链接: ${r.job_detail_url}`,
|
|
117
|
-
``,
|
|
118
|
-
`【职位描述】`,
|
|
119
|
-
r.job_description ?? '(无描述)',
|
|
120
|
-
].join('\n');
|
|
121
|
-
|
|
122
|
-
return { content: [{ type: 'text', text }] };
|
|
123
|
-
}
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// ── list_jobs ─────────────────────────────────────────────────────────────────
|
|
127
|
-
server.tool('list_jobs',
|
|
128
|
-
'列出最新职位,支持按类型、地点过滤。用于浏览近期职位。',
|
|
129
|
-
{
|
|
130
|
-
job_type: z.string().optional().describe('职位类型过滤,如 实习、校招、社招'),
|
|
131
|
-
location: z.string().optional().describe('工作地点过滤'),
|
|
132
|
-
limit: z.number().optional().describe('返回数量,默认 20,最大 50'),
|
|
133
|
-
offset: z.number().optional().describe('跳过前 N 条,用于分页'),
|
|
134
|
-
},
|
|
135
|
-
async ({ job_type, location, limit = 20, offset = 0 }) => {
|
|
136
|
-
const max = Math.min(limit, 50);
|
|
137
|
-
const conditions = ['is_valid != "false"'];
|
|
138
|
-
const params = [];
|
|
139
|
-
|
|
140
|
-
if (job_type) { conditions.push('job_type LIKE ?'); params.push(`%${job_type}%`); }
|
|
141
|
-
if (location) { conditions.push('work_location LIKE ?'); params.push(`%${location}%`); }
|
|
142
|
-
|
|
143
|
-
params.push(max, offset);
|
|
144
|
-
const listSql = `SELECT job_id, job_title, company_name, salary, work_location, job_type, degree, tags FROM job_details WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
|
145
|
-
console.error(`[mysql-mcp] list_jobs: ${listSql} | params: ${JSON.stringify(params)}`);
|
|
146
|
-
const [rows] = await pool.query(listSql, params);
|
|
147
|
-
|
|
148
|
-
if (rows.length === 0) {
|
|
149
|
-
return { content: [{ type: 'text', text: '暂无职位数据。' }] };
|
|
30
|
+
export function createMysqlMcpServer({
|
|
31
|
+
env = process.env,
|
|
32
|
+
fetchFn = globalThis.fetch,
|
|
33
|
+
mysqlModule = mysql,
|
|
34
|
+
logger = console.error,
|
|
35
|
+
} = {}) {
|
|
36
|
+
const brokerClient = createCredentialBrokerClient({
|
|
37
|
+
fetchFn,
|
|
38
|
+
serverUrl: env.SERVER_URL,
|
|
39
|
+
machineApiKey: env.MACHINE_API_KEY,
|
|
40
|
+
agentId: env.AGENT_ID,
|
|
41
|
+
workspaceId: env.WORKSPACE_ID,
|
|
42
|
+
bundleId: env.GOVERNANCE_SPAWN_BUNDLE_ID,
|
|
43
|
+
});
|
|
44
|
+
const poolRegistry = createDataSourcePoolRegistry({
|
|
45
|
+
createPool: mysqlModule.createPool.bind(mysqlModule),
|
|
46
|
+
logger,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const server = new McpServer({ name: 'mysql-universal-sql', version: '0.2.0' });
|
|
50
|
+
|
|
51
|
+
server.tool(
|
|
52
|
+
'query',
|
|
53
|
+
'执行通用 SQL。调用时通过 data_source_id 走 credential broker 动态获取 DB 凭证并路由连接。',
|
|
54
|
+
TOOL_SCHEMA,
|
|
55
|
+
async ({ data_source_id, sql, params = [], limit }) => {
|
|
56
|
+
try {
|
|
57
|
+
const payload = await executeSql({
|
|
58
|
+
dataSourceId: data_source_id,
|
|
59
|
+
sql,
|
|
60
|
+
params,
|
|
61
|
+
limit,
|
|
62
|
+
fetchCredentialEnvVars: brokerClient,
|
|
63
|
+
poolRegistry,
|
|
64
|
+
});
|
|
65
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger(`[mysql-mcp] query failed for data_source_id=${data_source_id}: ${error.message}`);
|
|
68
|
+
return {
|
|
69
|
+
isError: true,
|
|
70
|
+
content: [{ type: 'text', text: `query failed: ${error.message}` }],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
150
73
|
}
|
|
74
|
+
);
|
|
151
75
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
).join('\n');
|
|
76
|
+
return { server, poolRegistry };
|
|
77
|
+
}
|
|
155
78
|
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
);
|
|
79
|
+
export async function startMysqlMcpServer(options = {}) {
|
|
80
|
+
const { server } = createMysqlMcpServer(options);
|
|
81
|
+
const transport = new StdioServerTransport();
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
console.error('[mysql-mcp] Server started (universal SQL mode)');
|
|
84
|
+
}
|
|
159
85
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
86
|
+
if (isExecutedDirectly(import.meta.url)) {
|
|
87
|
+
startMysqlMcpServer().catch((error) => {
|
|
88
|
+
console.error(`[mysql-mcp] failed to start: ${error.message}`);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "mysql",
|
|
3
3
|
"name": "MySQL MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"runtime": "node",
|
|
6
6
|
"entrypoint": "index.js",
|
|
7
7
|
"tool_declarations": [
|
|
8
|
-
{ "name": "
|
|
9
|
-
{ "name": "get_job_detail", "classification": "cacheable" },
|
|
10
|
-
{ "name": "list_jobs", "classification": "cacheable" }
|
|
8
|
+
{ "name": "query", "classification": "mandatory" }
|
|
11
9
|
],
|
|
12
10
|
"smoke_test": {
|
|
13
|
-
"tool": "
|
|
14
|
-
"arguments": {
|
|
11
|
+
"tool": "query",
|
|
12
|
+
"arguments": {
|
|
13
|
+
"data_source_id": "smoke-test-data-source",
|
|
14
|
+
"sql": "SELECT 1",
|
|
15
|
+
"limit": 1
|
|
16
|
+
}
|
|
15
17
|
}
|
|
16
18
|
}
|
package/package.json
CHANGED
package/src/chat-bridge.js
CHANGED
|
@@ -115,6 +115,7 @@ const DEFAULT_TOOL_CLASSIFICATION = {
|
|
|
115
115
|
update_goal_field: 'mandatory',
|
|
116
116
|
supersede_goal_field: 'mandatory',
|
|
117
117
|
request_credential_auth: 'mandatory',
|
|
118
|
+
register_data_source: 'mandatory',
|
|
118
119
|
bind_workspace_scenario: 'mandatory',
|
|
119
120
|
create_workspace: 'mandatory',
|
|
120
121
|
rename_workspace: 'mandatory',
|
|
@@ -170,6 +171,7 @@ const CACHE_INVALIDATION_TOOLS = new Set([
|
|
|
170
171
|
'update_goal_field',
|
|
171
172
|
'supersede_goal_field',
|
|
172
173
|
'request_credential_auth',
|
|
174
|
+
'register_data_source',
|
|
173
175
|
'bind_workspace_scenario',
|
|
174
176
|
'create_workspace',
|
|
175
177
|
'rename_workspace',
|
|
@@ -246,6 +248,7 @@ function inferToolForApi(method, apiPath, body) {
|
|
|
246
248
|
if (method === 'POST' && cleanPath === '/goal-fields/update') return 'update_goal_field';
|
|
247
249
|
if (method === 'POST' && cleanPath === '/goal-fields/supersede') return 'supersede_goal_field';
|
|
248
250
|
if (method === 'POST' && cleanPath === '/credential-auth/request') return 'request_credential_auth';
|
|
251
|
+
if (method === 'POST' && cleanPath === '/api/data-sources') return 'register_data_source';
|
|
249
252
|
if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
|
|
250
253
|
if (method === 'POST' && cleanPath === '/orchestrate/correction') return 'write_governance_correction';
|
|
251
254
|
if (method === 'GET' && cleanPath === '/orchestrate/context') return 'get_orchestrate_context';
|
|
@@ -1117,6 +1120,45 @@ server.tool('request_credential_auth',
|
|
|
1117
1120
|
}
|
|
1118
1121
|
);
|
|
1119
1122
|
|
|
1123
|
+
// ── register_data_source ───────────────────────────────────────────────────────
|
|
1124
|
+
server.tool('register_data_source',
|
|
1125
|
+
'Register a workspace data source without binding credential yet. Returns a one-time secure auth URL (10-minute expiry) that should be sent to the user via IM.',
|
|
1126
|
+
{
|
|
1127
|
+
workspace_id: z.string().optional().describe('Target workspace id. Defaults to current workspace context if omitted.'),
|
|
1128
|
+
display_name: z.string().describe('Human-readable data source name.'),
|
|
1129
|
+
source_type: z.string().describe('Data source type, e.g. "mysql", "postgresql", "api", "csv", "rss", "google_sheets".'),
|
|
1130
|
+
schema_hint: z.any().optional().describe('Optional schema hint JSON for downstream query planning.'),
|
|
1131
|
+
},
|
|
1132
|
+
async ({ workspace_id, display_name, source_type, schema_hint }) => {
|
|
1133
|
+
const targetWorkspaceId = (workspace_id ?? currentWorkspaceId ?? WORKSPACE_ID ?? '').trim();
|
|
1134
|
+
if (!targetWorkspaceId) {
|
|
1135
|
+
return { isError: true, content: [{ type: 'text', text: 'workspace_id is required (no current workspace context).' }] };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const body = {
|
|
1139
|
+
workspace_id: targetWorkspaceId,
|
|
1140
|
+
display_name,
|
|
1141
|
+
source_type,
|
|
1142
|
+
};
|
|
1143
|
+
if (schema_hint !== undefined) body.schema_hint = schema_hint;
|
|
1144
|
+
|
|
1145
|
+
const data = await api('POST', '/api/data-sources', body);
|
|
1146
|
+
return {
|
|
1147
|
+
content: [{
|
|
1148
|
+
type: 'text',
|
|
1149
|
+
text:
|
|
1150
|
+
`Data source registered.\n` +
|
|
1151
|
+
`workspace_id=${data.workspace_id ?? targetWorkspaceId}\n` +
|
|
1152
|
+
`data_source_id=${data.data_source_id}\n` +
|
|
1153
|
+
`source_type=${data.source_type ?? source_type}\n` +
|
|
1154
|
+
`expires_at=${data.expires_at ?? 'unknown'}\n` +
|
|
1155
|
+
`secure_auth_url=${data.secure_auth_url}\n\n` +
|
|
1156
|
+
`Send secure_auth_url to the user in IM so they can complete credential binding securely.`,
|
|
1157
|
+
}],
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1120
1162
|
// ── request_approval ──────────────────────────────────────────────────────────
|
|
1121
1163
|
server.tool('request_approval',
|
|
1122
1164
|
'Request human approval before executing a sensitive platform action (posting, sending, publishing). Returns an action_id. After the human approves, call execute_approved_action with that ID.',
|
package/src/mcp-config.js
CHANGED
|
@@ -58,7 +58,8 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceI
|
|
|
58
58
|
return { SERVER_URL: serverUrl, MACHINE_API_KEY: authToken, AGENT_ID: agentId };
|
|
59
59
|
}
|
|
60
60
|
if (
|
|
61
|
-
serverKey === '
|
|
61
|
+
serverKey === 'mysql'
|
|
62
|
+
|| serverKey === 'publisher'
|
|
62
63
|
|| serverKey === 'platform'
|
|
63
64
|
|| serverKey === 'research-fetch'
|
|
64
65
|
|| serverKey === 'market-data-query'
|