@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.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/CHANGELOG.md +24 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/bin/nl2sql-mcp.mjs +103 -0
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/index.js +17 -1
- package/lib/client.js +1680 -6
- package/package.json +7 -2
- package/service/alerts.js +20 -6
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +3 -2
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
package/service/mcp.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server: other AI tools (Claude Desktop, Cursor, Cline, VS Code) reach the
|
|
3
|
+
* plugin's governed data access through the Model Context Protocol.
|
|
4
|
+
*
|
|
5
|
+
* Transport: Streamable HTTP on 127.0.0.1 only (POST /mcp, plain JSON responses;
|
|
6
|
+
* GET / DELETE → 405), disabled by default. Dual-era: legacy clients use the
|
|
7
|
+
* `initialize` handshake (2024-11-05 … 2025-11-25, Mcp-Session-Id minted on
|
|
8
|
+
* initialize); modern clients (2026-07-28) send per-request `_meta` and may call
|
|
9
|
+
* `server/discover`. No dependencies beyond node:http / node:crypto.
|
|
10
|
+
*
|
|
11
|
+
* Security: bearer tokens created in the page (only SHA-256 hashes are stored);
|
|
12
|
+
* every token maps to an actor — the owner, an existing member (scope, role,
|
|
13
|
+
* row rules), or a role + data source list — and every query goes through
|
|
14
|
+
* runQuery (read-only admission, access policy, row-level rules, masking,
|
|
15
|
+
* audit with source 'mcp'). Foreign Origin → 403, non-local Host → 403
|
|
16
|
+
* (DNS-rebinding defence), missing / invalid token → 401.
|
|
17
|
+
*
|
|
18
|
+
* State: state.mcp = { enabled, port, tokens: [{ id, name, hash, prefix, actor, createdAt, lastUsedAt }] }.
|
|
19
|
+
*/
|
|
20
|
+
import { createServer } from 'node:http'
|
|
21
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { DIALECTS } from '../drivers.js'
|
|
24
|
+
import { UserError, clampInt, newId, oneOf, requireText, summarize } from './shared.js'
|
|
25
|
+
|
|
26
|
+
export const MCP_DEFAULT_PORT = 47860
|
|
27
|
+
export const MCP_PATH = '/mcp'
|
|
28
|
+
/** Newest first. 2026-07-28 is stateless (per-request _meta); the others use initialize. */
|
|
29
|
+
export const MCP_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
|
|
30
|
+
const MODERN = new Set(['2026-07-28'])
|
|
31
|
+
const LATEST_LEGACY = '2025-11-25'
|
|
32
|
+
const MAX_BODY = 1 << 20
|
|
33
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
34
|
+
const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
|
|
35
|
+
const SERVER_INFO = { name: 'nl2sql', title: 'DeepSeek Harness Data Q&A (nl2sql)', version: PKG.version ?? '0.0.0' }
|
|
36
|
+
const INSTRUCTIONS = 'Governed, read-only access to enterprise databases configured in DeepSeek Harness Data Q&A. Start with list_datasources, then get_schema (overview, then specific tables) and search_knowledge for verified examples and metric definitions before writing SQL. Prefer query_metric for metrics in the metric library. Every statement is admitted read-only; access policies, member scopes and row-level rules are enforced server-side and every call is audited — do not try to work around refusals.'
|
|
37
|
+
|
|
38
|
+
const DS_PARAM = { type: 'string', description: 'Data source id or name from list_datasources. Omit to use the default one for this token.' }
|
|
39
|
+
|
|
40
|
+
export const hashToken = (token) => createHash('sha256').update(String(token)).digest('hex')
|
|
41
|
+
|
|
42
|
+
/** Host header names a loopback host (any port). */
|
|
43
|
+
export function isLocalHost(host) {
|
|
44
|
+
if (!host) return false
|
|
45
|
+
const h = String(host).trim().toLowerCase()
|
|
46
|
+
const name = h.startsWith('[') ? h.slice(0, h.indexOf(']') + 1) : h.replace(/:\d+$/, '')
|
|
47
|
+
return LOCAL_HOSTS.has(name)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Origin header absent, or a loopback origin. */
|
|
51
|
+
export function isLocalOrigin(origin) {
|
|
52
|
+
if (origin === undefined || origin === null || origin === '') return true
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(origin)
|
|
55
|
+
return (u.protocol === 'http:' || u.protocol === 'https:') && LOCAL_HOSTS.has(u.hostname.toLowerCase())
|
|
56
|
+
} catch { return false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Decode the `=?base64?…?=` sentinel used by Mcp-Name / Mcp-Param-* headers. */
|
|
60
|
+
function decodeHeaderValue(v) {
|
|
61
|
+
const m = /^=\?base64\?(.*)\?=$/.exec(v ?? '')
|
|
62
|
+
return m ? Buffer.from(m[1], 'base64').toString('utf8') : v
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class RpcError extends Error {
|
|
66
|
+
constructor(code, message, data, status) { super(message); this.rpcCode = code; this.data = data; this.status = status }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tool definitions for external models. `extra(service)` decides runtime-optional tools. */
|
|
70
|
+
function toolDefs(service) {
|
|
71
|
+
const ro = { readOnlyHint: true, openWorldHint: false }
|
|
72
|
+
const defs = [
|
|
73
|
+
{
|
|
74
|
+
name: 'list_datasources',
|
|
75
|
+
title: 'List data sources',
|
|
76
|
+
description: 'List the databases this token may query: id, name, engine (MySQL, PostgreSQL, Elasticsearch, SQLite for uploaded files), database name, description and the SQL dialect to use. Call this first.',
|
|
77
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
78
|
+
annotations: ro,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'get_schema',
|
|
82
|
+
title: 'Read schema',
|
|
83
|
+
description: 'Read the analyzed schema of a data source. Without `tables`: a compact overview of every visible table (role, approximate rows, comment, columns with semantic roles such as time/measure/enum/id/sensitive), relations, the SQL dialect notes, glossary and metric definitions. With `tables`: full column types, comments, indexes and sampled enum values for those tables. Tables and columns hidden by the access policy are not listed and cannot be queried.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
datasource: DS_PARAM,
|
|
88
|
+
tables: { type: 'array', items: { type: 'string' }, description: 'Table names to describe in detail.' },
|
|
89
|
+
question: { type: 'string', description: 'The user question; on large databases the overview then describes the most relevant tables in full.' },
|
|
90
|
+
},
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
annotations: ro,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'query',
|
|
97
|
+
title: 'Run read-only SQL',
|
|
98
|
+
description: 'Execute ONE read-only SQL statement (SELECT / WITH / SHOW / EXPLAIN) on a data source, written in its dialect: MySQL (backtick identifiers; 5.7 has no CTE/window functions; ONLY_FULL_GROUP_BY), PostgreSQL (date_trunc, CTEs, window functions), Elasticsearch SQL (indices are tables, no JOINs) or SQLite. Writes, DDL, multiple statements and system catalogs are refused. Rows are capped and a time limit applies; sensitive columns may be masked; row-level rules of this token are added automatically (then subqueries/CTE/UNION are refused). Depending on the privacy setting you receive the first rows, per-column statistics, or only the row count — plus a resultId usable with drill / explain_change. Prefer aggregated queries.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
sql: { type: 'string', description: 'The single read-only SQL statement.' },
|
|
103
|
+
datasource: DS_PARAM,
|
|
104
|
+
title: { type: 'string', description: 'Short human title for the result (shown in the audit log).' },
|
|
105
|
+
confirmScan: { type: 'boolean', description: 'Set true only after a scan-guard refusal when the user accepts a full scan of a large table.' },
|
|
106
|
+
},
|
|
107
|
+
required: ['sql'],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
annotations: ro,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'query_metric',
|
|
114
|
+
title: 'Query a governed metric',
|
|
115
|
+
description: 'Query a metric from the metric library by name or synonym. Its expression, table, filter and time column are fixed by the definition (see get_schema / search_knowledge) — do not re-derive them in SQL. Optional time grain, dimensions (allowed ones only), time range and filters.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
metric: { type: 'string', description: 'Metric name or synonym.' },
|
|
120
|
+
datasource: DS_PARAM,
|
|
121
|
+
grain: { type: 'string', enum: ['hour', 'day', 'week', 'month', 'year'], description: 'Time bucket; omit for a single total.' },
|
|
122
|
+
dimensions: { type: 'array', items: { type: 'string' }, description: 'Columns to split by.' },
|
|
123
|
+
range: { type: 'string', enum: ['today', 'yesterday', 'last7d', 'last30d', 'last90d', 'thisMonth', 'lastMonth', 'thisYear'] },
|
|
124
|
+
from: { type: 'string', description: 'Start date (inclusive), YYYY-MM-DD.' },
|
|
125
|
+
to: { type: 'string', description: 'End date (exclusive), YYYY-MM-DD.' },
|
|
126
|
+
filters: { type: 'array', items: { type: 'object', properties: { column: { type: 'string' }, op: { type: 'string', enum: ['=', '!=', 'in', '>', '>=', '<', '<='] }, value: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, required: ['column', 'op'], additionalProperties: false } },
|
|
127
|
+
title: { type: 'string' },
|
|
128
|
+
},
|
|
129
|
+
required: ['metric'],
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
},
|
|
132
|
+
annotations: ro,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'search_knowledge',
|
|
136
|
+
title: 'Search business knowledge',
|
|
137
|
+
description: 'Find verified question→SQL examples, glossary terms and metric definitions related to a question. Call it before writing SQL for a non-trivial question and reuse matching definitions.',
|
|
138
|
+
inputSchema: { type: 'object', properties: { question: { type: 'string' }, datasource: DS_PARAM }, required: ['question'], additionalProperties: false },
|
|
139
|
+
annotations: ro,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'drill',
|
|
143
|
+
title: 'Drill into a result',
|
|
144
|
+
description: 'Drill into a previous result by resultId: mode=breakdown splits the result (or one x value of it) by a dimension column; mode=detail lists raw rows behind one x value. Row-level rules and policies are re-applied.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
resultId: { type: 'string' },
|
|
149
|
+
mode: { type: 'string', enum: ['breakdown', 'detail'] },
|
|
150
|
+
xValue: { type: 'string', description: 'The x value (category or time bucket) to drill into.' },
|
|
151
|
+
dimension: { type: 'string', description: 'breakdown: column to split by.' },
|
|
152
|
+
},
|
|
153
|
+
required: ['resultId', 'mode'],
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
},
|
|
156
|
+
annotations: ro,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'explain_change',
|
|
160
|
+
title: 'Explain a change',
|
|
161
|
+
description: 'Compare two periods of a time-series result (resultId from query / query_metric) and rank dimensions by how much of the change they explain (attribution). Defaults to the last two points.',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
resultId: { type: 'string' },
|
|
166
|
+
current: { type: 'string', description: 'Current period x value (default: last point).' },
|
|
167
|
+
baseline: { type: 'string', description: 'Baseline period x value (default: previous point).' },
|
|
168
|
+
dimensions: { type: 'array', items: { type: 'string' }, description: 'Candidate dimensions (default: inferred).' },
|
|
169
|
+
},
|
|
170
|
+
required: ['resultId'],
|
|
171
|
+
additionalProperties: false,
|
|
172
|
+
},
|
|
173
|
+
annotations: ro,
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
if (typeof service.reconcile === 'function') {
|
|
177
|
+
const side = (d) => ({ type: 'object', description: d, properties: { sql: { type: 'string', description: 'One read-only SQL statement in that data source\'s dialect.' }, datasource: DS_PARAM }, required: ['sql'], additionalProperties: false })
|
|
178
|
+
const pair = { type: 'array', items: { type: 'object', properties: { left: { type: 'string' }, right: { type: 'string' }, tolerance: { type: 'number' }, relative: { type: 'boolean' } }, required: ['left'], additionalProperties: false } }
|
|
179
|
+
defs.push({
|
|
180
|
+
name: 'reconcile',
|
|
181
|
+
title: 'Reconcile two query results',
|
|
182
|
+
description: 'Reconcile / compare two read-only queries — typically the same business numbers from two data sources. Joins rows on key columns and reports matched keys, keys only on one side, value mismatches per compare column (optional numeric tolerance; relative=true for a fraction) and per-side totals. Each side runs through the same admission, policy and row-level rules as query.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
left: side('Left (reference) side'),
|
|
187
|
+
right: side('Right side'),
|
|
188
|
+
keys: { ...pair, description: 'Key columns identifying a row on both sides; right defaults to left.' },
|
|
189
|
+
compare: { ...pair, description: 'Columns whose values must agree.' },
|
|
190
|
+
title: { type: 'string' },
|
|
191
|
+
},
|
|
192
|
+
required: ['left', 'right', 'keys'],
|
|
193
|
+
additionalProperties: false,
|
|
194
|
+
},
|
|
195
|
+
annotations: ro,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
if (typeof service.forecast === 'function') {
|
|
199
|
+
defs.push({
|
|
200
|
+
name: 'forecast',
|
|
201
|
+
title: 'Forecast',
|
|
202
|
+
description: 'Forecast a time-series result (resultId from query / query_metric) a few periods ahead with the plugin\'s forecasting feature (read-only).',
|
|
203
|
+
inputSchema: { type: 'object', properties: { resultId: { type: 'string' }, horizon: { type: 'integer', minimum: 1, maximum: 90 } }, required: ['resultId'], additionalProperties: true },
|
|
204
|
+
annotations: ro,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
return defs
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const PROMPTS = [
|
|
211
|
+
{
|
|
212
|
+
name: 'ask_data',
|
|
213
|
+
title: 'Answer a data question',
|
|
214
|
+
description: 'Answer a business question from the governed databases.',
|
|
215
|
+
arguments: [{ name: 'question', description: 'The business question', required: true }, { name: 'datasource', description: 'Data source name (optional)', required: false }],
|
|
216
|
+
},
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
export const methods = {
|
|
220
|
+
/** state.mcp, initialized lazily. */
|
|
221
|
+
mcpState() {
|
|
222
|
+
const s = this.state.mcp ??= {}
|
|
223
|
+
s.enabled = Boolean(s.enabled)
|
|
224
|
+
s.port ??= MCP_DEFAULT_PORT
|
|
225
|
+
s.tokens ??= []
|
|
226
|
+
return s
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
mcpPublicToken(tok) {
|
|
230
|
+
const a = tok.actor ?? { kind: 'owner' }
|
|
231
|
+
let scope
|
|
232
|
+
if (a.memberId) {
|
|
233
|
+
const u = (this.state.users ?? []).find(x => x.id === a.memberId)
|
|
234
|
+
scope = { type: 'member', memberId: a.memberId, memberName: u?.name ?? null, missing: !u, disabled: Boolean(u?.disabled) }
|
|
235
|
+
} else if (a.role) {
|
|
236
|
+
scope = { type: 'role', role: a.role, datasourceIds: a.datasourceIds ?? null, datasourceNames: Array.isArray(a.datasourceIds) ? a.datasourceIds.map(id => this.findDatasource(id)?.name ?? id) : null, rowRules: a.rowRules ?? [] }
|
|
237
|
+
} else scope = { type: 'owner' }
|
|
238
|
+
return { id: tok.id, name: tok.name, prefix: tok.prefix, scope, createdAt: tok.createdAt, lastUsedAt: tok.lastUsedAt ?? null }
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
mcpStatus() {
|
|
242
|
+
const s = this.mcpState()
|
|
243
|
+
const running = Boolean(this.mcpServer?.listening)
|
|
244
|
+
const port = running ? this.mcpServer.address().port : s.port
|
|
245
|
+
return {
|
|
246
|
+
enabled: s.enabled, port: s.port, running, error: this.mcpError ?? null,
|
|
247
|
+
url: `http://127.0.0.1:${port}${MCP_PATH}`,
|
|
248
|
+
protocolVersions: MCP_VERSIONS,
|
|
249
|
+
tokens: s.tokens.map(t => this.mcpPublicToken(t)),
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
async saveMcpSettings(input = {}) {
|
|
254
|
+
const s = this.mcpState()
|
|
255
|
+
const before = JSON.stringify([s.enabled, s.port])
|
|
256
|
+
if (input.enabled !== undefined) s.enabled = Boolean(input.enabled)
|
|
257
|
+
if (input.port !== undefined && input.port !== null && input.port !== '') s.port = clampInt(Number(input.port), 0, 65535, 'port')
|
|
258
|
+
const changed = JSON.stringify([s.enabled, s.port]) !== before
|
|
259
|
+
if (changed) this.auditAdmin('mcp.settings', `${s.enabled ? 'enabled' : 'disabled'} :${s.port}`)
|
|
260
|
+
await this.save()
|
|
261
|
+
if (changed || (s.enabled && !this.mcpServer)) await this.applyMcp()
|
|
262
|
+
return this.mcpStatus()
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/** Validate a token scope: { kind:'owner' } | { memberId } | { role, datasourceIds, rowRules? }. */
|
|
266
|
+
mcpNormalizeScope(scope) {
|
|
267
|
+
const sc = scope ?? { kind: 'owner' }
|
|
268
|
+
if (sc.memberId) {
|
|
269
|
+
const u = (this.state.users ?? []).find(x => x.id === sc.memberId)
|
|
270
|
+
if (!u) throw new UserError('nl2sql/invalid', `成员不存在:${sc.memberId}`)
|
|
271
|
+
return { memberId: u.id }
|
|
272
|
+
}
|
|
273
|
+
if (sc.role) {
|
|
274
|
+
const role = oneOf(sc.role, ['admin', 'analyst', 'viewer'], 'role')
|
|
275
|
+
const datasourceIds = sc.datasourceIds === null || sc.allDatasources ? null : (Array.isArray(sc.datasourceIds) ? sc.datasourceIds : []).filter(id => this.findDatasource(id))
|
|
276
|
+
if (Array.isArray(datasourceIds) && !datasourceIds.length) throw new UserError('nl2sql/invalid', '请至少选择一个数据源')
|
|
277
|
+
const rowRules = (Array.isArray(sc.rowRules) ? sc.rowRules : []).map(r => ({
|
|
278
|
+
datasourceId: r.datasourceId && this.findDatasource(r.datasourceId) ? r.datasourceId : null,
|
|
279
|
+
column: requireText(r.column, 'rowRules.column', 64),
|
|
280
|
+
values: (Array.isArray(r.values) ? r.values : String(r.values ?? '').split(/[,,]/)).map(v => String(v).trim()).filter(Boolean).slice(0, 200),
|
|
281
|
+
})).filter(r => r.values.length).slice(0, 20)
|
|
282
|
+
if (rowRules.some(r => !/^[A-Za-z_\u4e00-\u9fa5][\w$\u4e00-\u9fa5]*$/.test(r.column))) throw new UserError('nl2sql/invalid', '行级规则的字段名只能包含字母、数字、下划线或中文')
|
|
283
|
+
return { role, datasourceIds, ...(rowRules.length ? { rowRules } : {}) }
|
|
284
|
+
}
|
|
285
|
+
if (sc.kind && sc.kind !== 'owner') throw new UserError('nl2sql/invalid', '无效的令牌范围')
|
|
286
|
+
return { kind: 'owner' }
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/** Create a token; the plain value is returned once and never stored. */
|
|
290
|
+
async createMcpToken({ name, scope } = {}) {
|
|
291
|
+
const s = this.mcpState()
|
|
292
|
+
const tokenName = requireText(name, 'name', 60)
|
|
293
|
+
if (s.tokens.some(t => t.name === tokenName)) throw new UserError('nl2sql/duplicate', `令牌名称已存在:${tokenName}`)
|
|
294
|
+
if (s.tokens.length >= 50) throw new UserError('nl2sql/invalid', '令牌数量已达上限(50)')
|
|
295
|
+
const actor = this.mcpNormalizeScope(scope)
|
|
296
|
+
const token = `n2s_mcp_${randomBytes(24).toString('base64url')}`
|
|
297
|
+
const rec = { id: newId('mt'), name: tokenName, hash: hashToken(token), prefix: token.slice(0, 12), actor, createdAt: Date.now(), lastUsedAt: null }
|
|
298
|
+
s.tokens.push(rec)
|
|
299
|
+
this.auditAdmin('mcp.token.create', `${tokenName}(${actor.memberId ? `member ${actor.memberId}` : actor.role ? `role ${actor.role}` : 'owner'})`)
|
|
300
|
+
await this.save()
|
|
301
|
+
return { ...this.mcpPublicToken(rec), token }
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async revokeMcpToken({ id } = {}) {
|
|
305
|
+
const s = this.mcpState()
|
|
306
|
+
const tok = s.tokens.find(t => t.id === id)
|
|
307
|
+
if (!tok) throw new UserError('nl2sql/invalid', '令牌不存在')
|
|
308
|
+
s.tokens = s.tokens.filter(t => t.id !== id)
|
|
309
|
+
for (const [sid, sess] of this.mcpSessions ?? []) if (sess.tokenId === id) this.mcpSessions.delete(sid)
|
|
310
|
+
this.auditAdmin('mcp.token.revoke', tok.name)
|
|
311
|
+
await this.save()
|
|
312
|
+
return { revoked: id }
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
/** The token record for a bearer value (constant-time hash comparison), or undefined. */
|
|
316
|
+
mcpFindToken(bearer) {
|
|
317
|
+
if (!bearer) return undefined
|
|
318
|
+
const h = Buffer.from(hashToken(bearer), 'hex')
|
|
319
|
+
let found
|
|
320
|
+
for (const t of this.mcpState().tokens) {
|
|
321
|
+
const other = Buffer.from(String(t.hash ?? ''), 'hex')
|
|
322
|
+
if (other.length === h.length && timingSafeEqual(other, h)) found = t
|
|
323
|
+
}
|
|
324
|
+
return found
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/** The actor a token runs as; re-resolved on every request so member changes apply at once. */
|
|
328
|
+
mcpActor(tok) {
|
|
329
|
+
const a = tok.actor ?? { kind: 'owner' }
|
|
330
|
+
const label = `MCP · ${tok.name}`
|
|
331
|
+
if (a.memberId) {
|
|
332
|
+
const u = (this.state.users ?? []).find(x => x.id === a.memberId)
|
|
333
|
+
if (!u || u.disabled) return undefined
|
|
334
|
+
return { kind: 'mcp', id: u.id, name: `${u.name}(${label})`, role: u.role, datasourceIds: u.datasourceIds ?? null, rowRules: u.rowRules ?? [], defaultDatasourceId: u.defaultDatasourceId ?? null, mcpTokenId: tok.id }
|
|
335
|
+
}
|
|
336
|
+
if (a.role) return { kind: 'mcp', id: `mcp:${tok.id}`, name: label, role: a.role, datasourceIds: a.datasourceIds ?? null, rowRules: a.rowRules ?? [], defaultDatasourceId: a.datasourceIds?.[0] ?? null, mcpTokenId: tok.id }
|
|
337
|
+
// Owner tokens: full access (assertAccess passes kind 'owner'), audited under the token name.
|
|
338
|
+
return { kind: 'owner', id: `mcp:${tok.id}`, name: label, mcpTokenId: tok.id }
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
mcpScoped(actor) { return Boolean(actor && actor.kind !== 'owner') },
|
|
342
|
+
|
|
343
|
+
mcpVisibleDatasources(actor) {
|
|
344
|
+
return this.state.datasources.filter(d => !this.mcpScoped(actor) || !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
/** Data source for an MCP call: explicit (checked), else the actor's default / first in scope, else the current one. */
|
|
348
|
+
mcpDatasource(actor, ref) {
|
|
349
|
+
if (ref) { const ds = this.requireDatasource(ref); this.assertAccess(actor, ds); return ds }
|
|
350
|
+
const visible = this.mcpVisibleDatasources(actor)
|
|
351
|
+
const pick = (this.mcpScoped(actor) ? this.findDatasource(actor.defaultDatasourceId) : this.findDatasource(this.state.current)) ?? visible[0]
|
|
352
|
+
if (!pick || !visible.includes(pick)) {
|
|
353
|
+
if (visible[0]) return visible[0]
|
|
354
|
+
throw new UserError('nl2sql/no-datasource', this.state.datasources.length ? 'This token has no accessible data source.' : 'No data source is configured yet.')
|
|
355
|
+
}
|
|
356
|
+
return pick
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
/** The part of a result an external model may see, per the data source privacy settings. */
|
|
360
|
+
mcpResultView(result) {
|
|
361
|
+
const settings = this.settingsFor(this.findDatasource(result.datasourceId))
|
|
362
|
+
const privacy = settings.resultToModel ?? 'summary'
|
|
363
|
+
const view = {
|
|
364
|
+
resultId: result.resultId,
|
|
365
|
+
datasource: result.datasourceName,
|
|
366
|
+
title: result.title || undefined,
|
|
367
|
+
sql: result.sql,
|
|
368
|
+
columns: result.columns.map(c => ({ name: c.name, kind: c.kind, ...(c.masked ? { masked: true } : {}) })),
|
|
369
|
+
rowCount: result.rowCount,
|
|
370
|
+
truncated: Boolean(result.truncated),
|
|
371
|
+
elapsedMs: result.elapsedMs,
|
|
372
|
+
privacy,
|
|
373
|
+
...(result.filters?.some(f => f.rowPolicy) ? { rowLevelFilters: result.filters.filter(f => f.rowPolicy).map(f => f.description) } : {}),
|
|
374
|
+
...(result.cached ? { cached: true } : {}),
|
|
375
|
+
}
|
|
376
|
+
if (privacy === 'rows') {
|
|
377
|
+
const n = settings.modelRows ?? 20
|
|
378
|
+
view.rows = result.rows.slice(0, n)
|
|
379
|
+
view.rowsReturned = view.rows.length
|
|
380
|
+
if (result.rowCount > view.rows.length) view.note = `Only the first ${view.rows.length} of ${result.rowCount} rows are returned (privacy setting). Aggregate in SQL instead of paging.`
|
|
381
|
+
} else if (privacy === 'summary') {
|
|
382
|
+
view.summary = summarize(result.columns, result.rows)
|
|
383
|
+
view.note = 'Rows are not shared with external models by the privacy setting; per-column statistics are returned. Aggregate in SQL to get specific figures.'
|
|
384
|
+
} else {
|
|
385
|
+
view.note = 'The privacy setting shares no row data or statistics with external models; only the row count and columns are returned.'
|
|
386
|
+
}
|
|
387
|
+
return JSON.parse(JSON.stringify(view, (_k, v) => (typeof v === 'bigint' ? v.toString() : typeof v === 'number' && !Number.isFinite(v) ? null : v)))
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/** Run one MCP tool as `actor`. Returns a plain object (structuredContent). */
|
|
391
|
+
async mcpCallTool(name, args, actor) {
|
|
392
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
393
|
+
const source = 'mcp'
|
|
394
|
+
switch (name) {
|
|
395
|
+
case 'list_datasources':
|
|
396
|
+
return {
|
|
397
|
+
datasources: this.mcpVisibleDatasources(actor).map(d => ({
|
|
398
|
+
id: d.id, name: d.name, type: d.type ?? 'mysql', database: d.database, description: d.description || undefined,
|
|
399
|
+
dialect: DIALECTS[d.type ?? 'mysql']?.sqlName, serverVersion: d.serverVersion || undefined,
|
|
400
|
+
})),
|
|
401
|
+
}
|
|
402
|
+
case 'get_schema': {
|
|
403
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
404
|
+
const schema = await this.visibleSchema(ds)
|
|
405
|
+
const tables = Array.isArray(a.tables) ? a.tables.map(String).slice(0, 50) : undefined
|
|
406
|
+
return { datasource: ds.name, tableCount: schema.tables.length, text: this.schemaForModel(schema, ds, tables, { question: typeof a.question === 'string' ? a.question : undefined }).replace(/nl2sql_schema/g, 'get_schema').replace(/nl2sql_knowledge action=search/g, 'search_knowledge') }
|
|
407
|
+
}
|
|
408
|
+
case 'query': {
|
|
409
|
+
if (typeof a.sql !== 'string' || !a.sql.trim()) throw new UserError('nl2sql/invalid', '`sql` is required')
|
|
410
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
411
|
+
const r = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: typeof a.title === 'string' ? a.title : '', source, actor, confirmScan: Boolean(a.confirmScan) })
|
|
412
|
+
return this.mcpResultView(r)
|
|
413
|
+
}
|
|
414
|
+
case 'query_metric': {
|
|
415
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
416
|
+
const r = await this.queryMetric({ metric: a.metric, grain: a.grain, dimensions: Array.isArray(a.dimensions) ? a.dimensions : [], range: a.range, from: a.from, to: a.to, filters: Array.isArray(a.filters) ? a.filters : [], title: a.title, datasourceId: ds.id, source, actor })
|
|
417
|
+
return { ...this.mcpResultView(r), metric: r.metric }
|
|
418
|
+
}
|
|
419
|
+
case 'search_knowledge': {
|
|
420
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
421
|
+
return { datasource: ds.name, ...this.searchKnowledge(ds.id, String(a.question ?? '')) }
|
|
422
|
+
}
|
|
423
|
+
case 'drill': {
|
|
424
|
+
if (!['breakdown', 'detail'].includes(a.mode)) throw new UserError('nl2sql/invalid', 'mode must be breakdown or detail')
|
|
425
|
+
const r = await this.drill({ resultId: a.resultId, mode: a.mode, xValue: a.xValue, dimension: a.dimension, actor, source })
|
|
426
|
+
return this.mcpResultView(r)
|
|
427
|
+
}
|
|
428
|
+
case 'explain_change': {
|
|
429
|
+
const r = await this.explainChange({ resultId: a.resultId, current: a.current, baseline: a.baseline, dimensions: Array.isArray(a.dimensions) ? a.dimensions : undefined, actor })
|
|
430
|
+
return { summary: r.summary, current: r.current, baseline: r.baseline, measure: r.measure, additive: r.additive, dimensions: r.dimensions.slice(0, 8).map(d => (d.attribution ? { dimension: d.dimension, concentration: Number(d.attribution.concentration?.toFixed?.(2) ?? d.attribution.concentration), total: d.attribution.total, top: d.attribution.items.slice(0, 5) } : { dimension: d.dimension, error: d.error })) }
|
|
431
|
+
}
|
|
432
|
+
case 'reconcile': {
|
|
433
|
+
if (typeof this.reconcile !== 'function') throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
434
|
+
this.assertCanWriteSql(actor, 'write reconciliation SQL')
|
|
435
|
+
const sideOf = (s) => ({ sql: s?.sql, datasourceId: this.mcpDatasource(actor, s?.datasource).id })
|
|
436
|
+
const out = await this.reconcile({ left: sideOf(a.left), right: sideOf(a.right), keys: a.keys, compare: a.compare ?? [], title: a.title, actor, source })
|
|
437
|
+
const privacy = [out.left.datasourceId, out.right.datasourceId].map(id => this.settingsFor(this.findDatasource(id)).resultToModel)
|
|
438
|
+
return JSON.parse(JSON.stringify({
|
|
439
|
+
title: out.title, left: out.left, right: out.right, summary: out.summary, warnings: out.warnings,
|
|
440
|
+
resultIds: Object.fromEntries(out.results.map(r => [r.reconcilePart, r.resultId])),
|
|
441
|
+
firstDiffs: privacy.every(p => p === 'rows') ? out.diffs.slice(0, 20) : `hidden by privacy settings (${privacy.join('/')})`,
|
|
442
|
+
}))
|
|
443
|
+
}
|
|
444
|
+
case 'forecast': {
|
|
445
|
+
if (typeof this[name] !== 'function') throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
446
|
+
if (a.resultId) { const r = this.lookupResult(a.resultId); if (!r) throw new UserError('nl2sql/result-expired', 'Result expired; run the query again.'); this.assertResultAccess(r, actor) }
|
|
447
|
+
const input = { ...a, actor, source }
|
|
448
|
+
if (a.datasource) { const ds = this.mcpDatasource(actor, a.datasource); input.datasourceId = ds.id }
|
|
449
|
+
const out = await this[name](input)
|
|
450
|
+
return out?.rows && out?.columns && out?.resultId ? this.mcpResultView(out) : JSON.parse(JSON.stringify(out ?? null))
|
|
451
|
+
}
|
|
452
|
+
default:
|
|
453
|
+
throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
/** Handle one JSON-RPC message. Returns { status, body?, headers? }. */
|
|
458
|
+
async mcpHandleMessage(msg, { tok, actor, headers, session }) {
|
|
459
|
+
const isObj = msg && typeof msg === 'object' && !Array.isArray(msg)
|
|
460
|
+
if (!isObj || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
461
|
+
// A JSON-RPC response from the client (legacy) is accepted and ignored.
|
|
462
|
+
if (isObj && msg.jsonrpc === '2.0' && ('result' in msg || 'error' in msg)) return { status: 202 }
|
|
463
|
+
return { status: 400, body: { jsonrpc: '2.0', id: isObj && 'id' in msg ? msg.id : null, error: { code: -32600, message: 'Invalid Request' } } }
|
|
464
|
+
}
|
|
465
|
+
const isNotification = !('id' in msg) || msg.id === undefined
|
|
466
|
+
const id = isNotification ? undefined : msg.id
|
|
467
|
+
const params = msg.params && typeof msg.params === 'object' ? msg.params : {}
|
|
468
|
+
const meta = params._meta ?? {}
|
|
469
|
+
const bodyVersion = meta['io.modelcontextprotocol/protocolVersion']
|
|
470
|
+
const headerVersion = headers['mcp-protocol-version']
|
|
471
|
+
const modern = Boolean(bodyVersion) && msg.method !== 'initialize'
|
|
472
|
+
const reply = (result, extra = {}) => ({ status: 200, body: { jsonrpc: '2.0', id, result: { ...(modern ? { _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO } } : {}), ...result, resultType: 'complete' } }, ...extra })
|
|
473
|
+
const fail = (code, message, data, status = 200) => ({ status, body: { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } } })
|
|
474
|
+
|
|
475
|
+
// Version checks: modern _meta version, header/body agreement, header version support.
|
|
476
|
+
if (modern) {
|
|
477
|
+
if (!MCP_VERSIONS.includes(bodyVersion)) return fail(-32022, 'Unsupported protocol version', { supported: MCP_VERSIONS, requested: bodyVersion }, 400)
|
|
478
|
+
if (headerVersion && headerVersion !== bodyVersion) return fail(-32020, `Header mismatch: MCP-Protocol-Version '${headerVersion}' does not match body '${bodyVersion}'`, undefined, 400)
|
|
479
|
+
if (headers['mcp-method'] && headers['mcp-method'] !== msg.method) return fail(-32020, `Header mismatch: Mcp-Method '${headers['mcp-method']}' does not match body '${msg.method}'`, undefined, 400)
|
|
480
|
+
const bodyName = params.name ?? params.uri
|
|
481
|
+
if (headers['mcp-name'] !== undefined && bodyName !== undefined && decodeHeaderValue(headers['mcp-name']) !== String(bodyName)) return fail(-32020, 'Header mismatch: Mcp-Name does not match the body', undefined, 400)
|
|
482
|
+
} else if (headerVersion && msg.method !== 'initialize' && !MCP_VERSIONS.includes(headerVersion)) {
|
|
483
|
+
return fail(-32022, 'Unsupported protocol version', { supported: MCP_VERSIONS, requested: headerVersion }, 400)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (isNotification) return { status: 202 } // notifications/initialized, cancelled, …
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
switch (msg.method) {
|
|
490
|
+
case 'initialize': {
|
|
491
|
+
const requested = String(params.protocolVersion ?? '')
|
|
492
|
+
// Legacy handshake: echo a supported legacy version, else offer our latest legacy one.
|
|
493
|
+
const version = MCP_VERSIONS.includes(requested) && !MODERN.has(requested) ? requested : LATEST_LEGACY
|
|
494
|
+
const sid = randomBytes(16).toString('hex')
|
|
495
|
+
this.mcpSessions ??= new Map()
|
|
496
|
+
this.mcpSessions.set(sid, { tokenId: tok.id, version, at: Date.now(), client: params.clientInfo?.name })
|
|
497
|
+
if (this.mcpSessions.size > 500) this.mcpSessions.delete(this.mcpSessions.keys().next().value)
|
|
498
|
+
return reply({ protocolVersion: version, capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, prompts: { listChanged: false } }, serverInfo: SERVER_INFO, instructions: INSTRUCTIONS }, { headers: { 'mcp-session-id': sid } })
|
|
499
|
+
}
|
|
500
|
+
case 'server/discover':
|
|
501
|
+
return reply({ supportedVersions: MCP_VERSIONS, capabilities: { tools: {}, resources: {}, prompts: {} }, _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO }, instructions: INSTRUCTIONS })
|
|
502
|
+
case 'ping':
|
|
503
|
+
return reply({})
|
|
504
|
+
case 'tools/list':
|
|
505
|
+
return reply({ tools: toolDefs(this) })
|
|
506
|
+
case 'tools/call': {
|
|
507
|
+
const name = String(params.name ?? '')
|
|
508
|
+
if (!toolDefs(this).some(t => t.name === name)) return fail(-32602, `Unknown tool: ${name}`)
|
|
509
|
+
try {
|
|
510
|
+
const value = await this.mcpCallTool(name, params.arguments ?? {}, actor)
|
|
511
|
+
const text = typeof value?.text === 'string' && Object.keys(value).length <= 3 ? value.text : JSON.stringify(value, null, 1)
|
|
512
|
+
return reply({ content: [{ type: 'text', text }], structuredContent: value && typeof value === 'object' && !Array.isArray(value) ? value : { value }, isError: false })
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (error instanceof RpcError) throw error
|
|
515
|
+
// Tool execution errors go back to the model as results so it can correct itself.
|
|
516
|
+
return reply({ content: [{ type: 'text', text: error.message ?? String(error) }], isError: true })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
case 'resources/list':
|
|
520
|
+
return reply({
|
|
521
|
+
resources: this.mcpVisibleDatasources(actor).map(d => ({
|
|
522
|
+
uri: `nl2sql://datasource/${encodeURIComponent(d.id)}/schema`, name: `${d.name} schema`, title: `${d.name} — schema overview`,
|
|
523
|
+
description: `Visible tables, columns, relations and dialect notes of ${d.name} (${DIALECTS[d.type ?? 'mysql']?.label ?? d.type}).`, mimeType: 'text/plain',
|
|
524
|
+
})),
|
|
525
|
+
})
|
|
526
|
+
case 'resources/templates/list':
|
|
527
|
+
return reply({ resourceTemplates: [{ uriTemplate: 'nl2sql://datasource/{id}/schema', name: 'Data source schema', description: 'Schema overview of a data source', mimeType: 'text/plain' }, { uriTemplate: 'nl2sql://datasource/{id}/table/{table}', name: 'Table schema', description: 'Full description of one table', mimeType: 'text/plain' }] })
|
|
528
|
+
case 'resources/read': {
|
|
529
|
+
const uri = String(params.uri ?? '')
|
|
530
|
+
const m = /^nl2sql:\/\/datasource\/([^/]+)\/(schema|table\/(.+))$/.exec(uri)
|
|
531
|
+
if (!m) return fail(-32002, 'Resource not found', { uri })
|
|
532
|
+
let ds
|
|
533
|
+
try { ds = this.mcpDatasource(actor, decodeURIComponent(m[1])) } catch { return fail(-32002, 'Resource not found', { uri }) }
|
|
534
|
+
const schema = await this.visibleSchema(ds)
|
|
535
|
+
const table = m[3] ? decodeURIComponent(m[3]) : undefined
|
|
536
|
+
if (table && !schema.tables.some(t => t.name.toLowerCase() === table.toLowerCase())) return fail(-32002, 'Resource not found', { uri })
|
|
537
|
+
const text = this.schemaForModel(schema, ds, table ? [table] : undefined).replace(/nl2sql_schema/g, 'get_schema').replace(/nl2sql_knowledge action=search/g, 'search_knowledge')
|
|
538
|
+
return reply({ contents: [{ uri, mimeType: 'text/plain', text }] })
|
|
539
|
+
}
|
|
540
|
+
case 'resources/subscribe':
|
|
541
|
+
case 'resources/unsubscribe':
|
|
542
|
+
return reply({})
|
|
543
|
+
case 'prompts/list':
|
|
544
|
+
return reply({ prompts: PROMPTS })
|
|
545
|
+
case 'prompts/get': {
|
|
546
|
+
if (params.name !== 'ask_data') return fail(-32602, `Unknown prompt: ${params.name}`)
|
|
547
|
+
const q = String(params.arguments?.question ?? '').trim()
|
|
548
|
+
if (!q) return fail(-32602, 'Missing required argument: question')
|
|
549
|
+
const dsName = params.arguments?.datasource ? ` on the data source "${params.arguments.datasource}"` : ''
|
|
550
|
+
return reply({ description: 'Answer a data question with the nl2sql tools', messages: [{ role: 'user', content: { type: 'text', text: `Answer this business question${dsName} using the nl2sql tools: list_datasources → get_schema (overview, then the relevant tables) → search_knowledge → query (one read-only, aggregated statement in the data source dialect) or query_metric. Explain the result briefly and show the SQL you used.\n\nQuestion: ${q}` } }] })
|
|
551
|
+
}
|
|
552
|
+
case 'logging/setLevel':
|
|
553
|
+
return reply({})
|
|
554
|
+
default:
|
|
555
|
+
return fail(-32601, `Method not found: ${msg.method}`, undefined, modern ? 404 : 200)
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (error instanceof RpcError) return fail(error.rpcCode, error.message, error.data, error.status ?? 200)
|
|
559
|
+
return fail(-32603, error.message ?? 'Internal error')
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
/** node:http request handler for the MCP endpoint. */
|
|
564
|
+
async mcpHandleHttp(req, res) {
|
|
565
|
+
const send = (status, body, headers = {}) => {
|
|
566
|
+
if (res.headersSent) return
|
|
567
|
+
const h = { 'cache-control': 'no-store', ...headers }
|
|
568
|
+
if (body === undefined) { res.writeHead(status, h); res.end(); return }
|
|
569
|
+
const text = JSON.stringify(body)
|
|
570
|
+
res.writeHead(status, { ...h, 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) })
|
|
571
|
+
res.end(text)
|
|
572
|
+
}
|
|
573
|
+
const rpcErr = (code, message) => ({ jsonrpc: '2.0', id: null, error: { code, message } })
|
|
574
|
+
// DNS-rebinding defence: loopback Host only, and no foreign Origin.
|
|
575
|
+
if (!isLocalHost(req.headers.host)) return send(403, rpcErr(-32600, 'Forbidden: Host must be localhost or 127.0.0.1'))
|
|
576
|
+
if (!isLocalOrigin(req.headers.origin)) return send(403, rpcErr(-32600, 'Forbidden: Origin not allowed'))
|
|
577
|
+
const path = (req.url ?? '/').split('?')[0]
|
|
578
|
+
if (path !== MCP_PATH) return send(404, rpcErr(-32600, `Not found; the MCP endpoint is ${MCP_PATH}`))
|
|
579
|
+
if (req.method !== 'POST') return send(405, rpcErr(-32600, 'Method Not Allowed: use POST'), { allow: 'POST' })
|
|
580
|
+
// Authentication.
|
|
581
|
+
const auth = String(req.headers.authorization ?? '')
|
|
582
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(auth)?.[1]
|
|
583
|
+
const tok = this.mcpFindToken(bearer)
|
|
584
|
+
const actor = tok ? this.mcpActor(tok) : undefined
|
|
585
|
+
if (!tok || !actor) return send(401, rpcErr(-32001, tok ? 'Unauthorized: the member of this token is disabled or deleted' : 'Unauthorized: missing or invalid bearer token'), { 'www-authenticate': 'Bearer realm="nl2sql-mcp"' })
|
|
586
|
+
// Body.
|
|
587
|
+
let size = 0
|
|
588
|
+
const chunks = []
|
|
589
|
+
try {
|
|
590
|
+
for await (const c of req) {
|
|
591
|
+
size += c.length
|
|
592
|
+
if (size > MAX_BODY) return send(413, rpcErr(-32600, 'Request body too large'))
|
|
593
|
+
chunks.push(c)
|
|
594
|
+
}
|
|
595
|
+
} catch { return }
|
|
596
|
+
let msg
|
|
597
|
+
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return send(400, rpcErr(-32700, 'Parse error')) }
|
|
598
|
+
// Legacy sessions: an unknown or foreign Mcp-Session-Id is a terminated session (404).
|
|
599
|
+
const sid = req.headers['mcp-session-id']
|
|
600
|
+
let session
|
|
601
|
+
if (sid && !(Array.isArray(msg) ? msg : [msg]).some(m => m?.method === 'initialize')) {
|
|
602
|
+
session = this.mcpSessions?.get(String(sid))
|
|
603
|
+
if (!session || session.tokenId !== tok.id) return send(404, rpcErr(-32001, 'Session not found; initialize again'))
|
|
604
|
+
}
|
|
605
|
+
// lastUsedAt, persisted at most once a minute per token.
|
|
606
|
+
const now = Date.now()
|
|
607
|
+
if (!tok.lastUsedAt || now - tok.lastUsedAt > 60000) { tok.lastUsedAt = now; this.save() } else tok.lastUsedAt = now
|
|
608
|
+
const ctx = { tok, actor, headers: req.headers, session }
|
|
609
|
+
if (Array.isArray(msg)) {
|
|
610
|
+
// JSON-RPC batch (2025-03-26): answer every request in one array.
|
|
611
|
+
if (!msg.length) return send(400, rpcErr(-32600, 'Invalid Request'))
|
|
612
|
+
const out = []
|
|
613
|
+
let extra = {}
|
|
614
|
+
for (const m of msg.slice(0, 50)) {
|
|
615
|
+
const r = await this.mcpHandleMessage(m, ctx)
|
|
616
|
+
if (r.body) out.push(r.body)
|
|
617
|
+
if (r.headers) extra = { ...extra, ...r.headers }
|
|
618
|
+
}
|
|
619
|
+
return out.length ? send(200, out, extra) : send(202)
|
|
620
|
+
}
|
|
621
|
+
const r = await this.mcpHandleMessage(msg, ctx)
|
|
622
|
+
return send(r.status, r.body, r.headers)
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
/** (Re)start or stop the server according to state.mcp. */
|
|
626
|
+
async applyMcp() {
|
|
627
|
+
await this.stopMcp()
|
|
628
|
+
const s = this.mcpState()
|
|
629
|
+
this.mcpError = null
|
|
630
|
+
if (!s.enabled || this.disposed) return this.mcpStatus()
|
|
631
|
+
const server = createServer((req, res) => {
|
|
632
|
+
this.mcpHandleHttp(req, res).catch(error => {
|
|
633
|
+
try { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: error.message } })) } } catch {}
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
server.requestTimeout = 15 * 60 * 1000
|
|
637
|
+
server.headersTimeout = 30000
|
|
638
|
+
try {
|
|
639
|
+
await new Promise((resolve, reject) => {
|
|
640
|
+
server.once('error', reject)
|
|
641
|
+
server.listen(s.port, '127.0.0.1', () => { server.off('error', reject); resolve() })
|
|
642
|
+
})
|
|
643
|
+
this.mcpServer = server
|
|
644
|
+
server.on('error', (e) => { this.mcpError = e.message })
|
|
645
|
+
} catch (error) {
|
|
646
|
+
this.mcpError = error.code === 'EADDRINUSE' ? `端口 ${s.port} 已被占用` : error.message
|
|
647
|
+
console.warn('nl2sql mcp: cannot listen on 127.0.0.1:%s: %s', s.port, error.message)
|
|
648
|
+
}
|
|
649
|
+
return this.mcpStatus()
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
async stopMcp() {
|
|
653
|
+
const server = this.mcpServer
|
|
654
|
+
this.mcpServer = undefined
|
|
655
|
+
this.mcpSessions?.clear()
|
|
656
|
+
if (!server) return
|
|
657
|
+
await new Promise(resolve => {
|
|
658
|
+
server.close(() => resolve())
|
|
659
|
+
server.closeAllConnections?.()
|
|
660
|
+
})
|
|
661
|
+
},
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export const FEATURE = {
|
|
665
|
+
name: 'mcp',
|
|
666
|
+
methods,
|
|
667
|
+
rpc: (service) => ({
|
|
668
|
+
mcpStatus: () => service.mcpStatus(),
|
|
669
|
+
saveMcpSettings: (p) => service.saveMcpSettings(p ?? {}),
|
|
670
|
+
createMcpToken: (p) => service.createMcpToken(p ?? {}),
|
|
671
|
+
revokeMcpToken: (p) => service.revokeMcpToken(p ?? {}),
|
|
672
|
+
}),
|
|
673
|
+
start(ctx, service) {
|
|
674
|
+
ctx.effect(() => () => service.stopMcp(), 'nl2sql: mcp server')
|
|
675
|
+
service.applyMcp().catch(error => console.warn('nl2sql mcp: start failed: %s', error.message))
|
|
676
|
+
},
|
|
677
|
+
}
|