@hedeqiang/dsh-nl2sql 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/CHANGELOG.md +20 -1
- package/bin/nl2sql-mcp.mjs +117 -40
- package/docs/mcp.md +152 -41
- package/drivers.js +16 -3
- package/lib/client.js +197 -14
- package/package.json +2 -2
- package/service/mcp.js +966 -100
- package/service/query.js +11 -3
- package/service/shared.js +9 -0
package/service/mcp.js
CHANGED
|
@@ -1,27 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP server: other AI tools (Claude Desktop,
|
|
3
|
-
*
|
|
2
|
+
* MCP server: other AI tools (Claude Desktop, Claude Code, Cursor, VS Code, Cline,
|
|
3
|
+
* Windsurf, Codex, Gemini CLI, Zed, Continue) reach the plugin's governed data
|
|
4
|
+
* access through the Model Context Protocol.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Specification revisions implemented (read from https://modelcontextprotocol.io/specification/):
|
|
7
|
+
* - 2026-07-28 (latest, stateless): https://modelcontextprotocol.io/specification/2026-07-28
|
|
8
|
+
* changelog https://modelcontextprotocol.io/specification/2026-07-28/changelog
|
|
9
|
+
* - 2025-11-25 / 2025-06-18 / 2025-03-26 / 2024-11-05 (initialize + Mcp-Session-Id):
|
|
10
|
+
* https://modelcontextprotocol.io/specification/2025-11-25/basic/transports
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Transport (Streamable HTTP, 127.0.0.1 only, disabled by default):
|
|
13
|
+
* https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
|
|
14
|
+
* - POST /mcp. A request whose Accept lists text/event-stream gets a request-scoped SSE
|
|
15
|
+
* stream for tools/call (notifications/progress + notifications/message, then the
|
|
16
|
+
* response; `:` comment heartbeats; X-Accel-Buffering: no). Everything else: JSON.
|
|
17
|
+
* - 2026-07-28: subscriptions/listen opens a long-lived SSE stream (acknowledged first,
|
|
18
|
+
* notifications tagged with io.modelcontextprotocol/subscriptionId); closing a stream
|
|
19
|
+
* cancels its request; logging level per request via _meta io.modelcontextprotocol/logLevel.
|
|
20
|
+
* - legacy: GET /mcp with Mcp-Session-Id opens the standalone SSE stream carrying
|
|
21
|
+
* notifications/resources/updated (resources/subscribe per session) and
|
|
22
|
+
* notifications/resources/list_changed; notifications/cancelled by request id;
|
|
23
|
+
* logging/setLevel per session; DELETE ends a session.
|
|
17
24
|
*
|
|
18
|
-
*
|
|
25
|
+
* Security: bearer tokens created in the page (only SHA-256 hashes are stored), optional
|
|
26
|
+
* expiry, tool allowlist and per-token rate limit; every token maps to an actor — the
|
|
27
|
+
* owner, an existing member (scope, role, row rules), or a role + data source list — and
|
|
28
|
+
* every query goes through runQuery (read-only admission, access policy, row-level rules,
|
|
29
|
+
* masking, audit with source 'mcp'). Foreign Origin → 403, non-local Host → 403
|
|
30
|
+
* (DNS-rebinding defence), missing / invalid / expired token → 401, rate limit → 429.
|
|
31
|
+
* Per-token call log: records kind 'mcpCall' (no result rows; SQL capped at 2000 chars).
|
|
32
|
+
*
|
|
33
|
+
* State: state.mcp = { enabled, port, tokens: [{ id, name, hash, prefix, actor, createdAt,
|
|
34
|
+
* lastUsedAt, expiresAt?, tools?, rateLimit? }] }.
|
|
19
35
|
*/
|
|
20
36
|
import { createServer } from 'node:http'
|
|
21
37
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
22
38
|
import { readFileSync } from 'node:fs'
|
|
23
39
|
import { DIALECTS } from '../drivers.js'
|
|
24
|
-
import {
|
|
40
|
+
import { admitSql } from '../sql-guard.js'
|
|
41
|
+
import { checkAccess } from '../policy.js'
|
|
42
|
+
import { UserError, callContext, clampInt, newId, oneOf, requireText, summarize } from './shared.js'
|
|
25
43
|
|
|
26
44
|
export const MCP_DEFAULT_PORT = 47860
|
|
27
45
|
export const MCP_PATH = '/mcp'
|
|
@@ -33,7 +51,15 @@ const MAX_BODY = 1 << 20
|
|
|
33
51
|
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
34
52
|
const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
|
|
35
53
|
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.'
|
|
54
|
+
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 (list_metrics). 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.'
|
|
55
|
+
/** RFC 5424 levels, https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging */
|
|
56
|
+
export const LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']
|
|
57
|
+
/** Page sizes for cursor pagination (https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination). */
|
|
58
|
+
export const PAGE_SIZE = { tools: 50, resources: 50, templates: 50, prompts: 50 }
|
|
59
|
+
const HEARTBEAT_MS = 15000
|
|
60
|
+
const CSV_MAX_ROWS = 500
|
|
61
|
+
const CSV_MAX_BYTES = 256 * 1024
|
|
62
|
+
const WATCH_MS = 5000
|
|
37
63
|
|
|
38
64
|
const DS_PARAM = { type: 'string', description: 'Data source id or name from list_datasources. Omit to use the default one for this token.' }
|
|
39
65
|
|
|
@@ -62,20 +88,99 @@ function decodeHeaderValue(v) {
|
|
|
62
88
|
return m ? Buffer.from(m[1], 'base64').toString('utf8') : v
|
|
63
89
|
}
|
|
64
90
|
|
|
91
|
+
/** Accept header lists text/event-stream. */
|
|
92
|
+
export function acceptsSse(accept) {
|
|
93
|
+
return /(^|,)\s*text\/event-stream\s*(;|,|$)/i.test(String(accept ?? ''))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Feature gates per negotiated revision (capability matrix in docs/mcp.md). */
|
|
97
|
+
export function revisionFeatures(version) {
|
|
98
|
+
const v = version ?? '2025-03-26'
|
|
99
|
+
return {
|
|
100
|
+
modern: MODERN.has(v),
|
|
101
|
+
structuredContent: v >= '2025-06-18', // structured tool output + outputSchema (2025-06-18 changelog #2)
|
|
102
|
+
resourceLinks: v >= '2025-06-18', // resource_link content (2025-06-18 changelog #7)
|
|
103
|
+
annotations: v >= '2025-03-26', // tool annotations (2025-03-26 changelog #4)
|
|
104
|
+
titles: v >= '2025-06-18', // `title` fields (2025-06-18 other changes #3)
|
|
105
|
+
completions: v >= '2025-03-26', // completions capability (2025-03-26 other changes)
|
|
106
|
+
batching: v === '2025-03-26', // JSON-RPC batches (added 2025-03-26, removed 2025-06-18)
|
|
107
|
+
setLevel: !MODERN.has(v), // logging/setLevel removed in 2026-07-28 (per-request _meta logLevel)
|
|
108
|
+
listen: MODERN.has(v), // subscriptions/listen replaces GET + resources/subscribe
|
|
109
|
+
getStream: !MODERN.has(v) && v >= '2025-03-26',
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
class RpcError extends Error {
|
|
66
114
|
constructor(code, message, data, status) { super(message); this.rpcCode = code; this.data = data; this.status = status }
|
|
67
115
|
}
|
|
68
116
|
|
|
69
|
-
/**
|
|
70
|
-
function
|
|
71
|
-
|
|
117
|
+
/** Opaque cursor: base64url of { o: offset, k: kind }. Invalid → -32602 per the pagination spec. */
|
|
118
|
+
function paginate(list, cursor, kind, size) {
|
|
119
|
+
let offset = 0
|
|
120
|
+
if (cursor !== undefined && cursor !== null) {
|
|
121
|
+
try {
|
|
122
|
+
const c = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'))
|
|
123
|
+
if (c.k !== kind || !Number.isInteger(c.o) || c.o < 0 || c.o > list.length) throw new Error('bad')
|
|
124
|
+
offset = c.o
|
|
125
|
+
} catch { throw new RpcError(-32602, 'Invalid cursor') }
|
|
126
|
+
}
|
|
127
|
+
const page = list.slice(offset, offset + size)
|
|
128
|
+
const next = offset + size < list.length ? Buffer.from(JSON.stringify({ k: kind, o: offset + size })).toString('base64url') : undefined
|
|
129
|
+
return { page, ...(next ? { nextCursor: next } : {}) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** CSV text (RFC 4180 quoting). */
|
|
133
|
+
export function toCsv(columns, rows) {
|
|
134
|
+
const cell = (v) => {
|
|
135
|
+
if (v === null || v === undefined) return ''
|
|
136
|
+
const s = typeof v === 'object' ? JSON.stringify(v) : String(v)
|
|
137
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
138
|
+
}
|
|
139
|
+
return [columns.map(c => cell(c.name ?? c)).join(','), ...rows.map(r => r.map(cell).join(','))].join('\r\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const jsonSafe = (v) => JSON.parse(JSON.stringify(v ?? null, (_k, x) => (typeof x === 'bigint' ? x.toString() : typeof x === 'number' && !Number.isFinite(x) ? null : x)))
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------------ output schemas
|
|
145
|
+
// https://modelcontextprotocol.io/specification/2026-07-28/server/tools#output-schema
|
|
146
|
+
// Keep to a small JSON Schema subset (type / properties / required / items / enum) so
|
|
147
|
+
// clients and the test checker validate it easily.
|
|
148
|
+
const S = {
|
|
149
|
+
str: { type: 'string' }, num: { type: 'number' }, int: { type: 'integer' }, bool: { type: 'boolean' },
|
|
150
|
+
arr: (items) => ({ type: 'array', items }), obj: (properties, required = []) => ({ type: 'object', properties, required }),
|
|
151
|
+
}
|
|
152
|
+
const RESULT_SCHEMA = S.obj({
|
|
153
|
+
resultId: S.str, datasource: S.str, title: S.str, sql: S.str,
|
|
154
|
+
columns: S.arr(S.obj({ name: S.str, kind: S.str, masked: S.bool }, ['name'])),
|
|
155
|
+
rowCount: S.int, truncated: S.bool, elapsedMs: S.num, privacy: { type: 'string', enum: ['none', 'summary', 'rows'] },
|
|
156
|
+
rows: S.arr({ type: 'array' }), rowsReturned: S.int, summary: S.arr({ type: 'object' }), note: S.str,
|
|
157
|
+
rowLevelFilters: S.arr(S.str), cached: S.bool, metric: { type: 'object' },
|
|
158
|
+
}, ['resultId', 'columns', 'rowCount', 'truncated', 'privacy'])
|
|
159
|
+
const OUTPUT_SCHEMAS = {
|
|
160
|
+
list_datasources: S.obj({ datasources: S.arr(S.obj({ id: S.str, name: S.str, type: S.str, database: S.str, description: S.str, dialect: S.str, serverVersion: S.str }, ['id', 'name', 'type'])) }, ['datasources']),
|
|
161
|
+
get_schema: S.obj({ datasource: S.str, tableCount: S.int, text: S.str }, ['datasource', 'tableCount', 'text']),
|
|
162
|
+
query: RESULT_SCHEMA,
|
|
163
|
+
query_metric: RESULT_SCHEMA,
|
|
164
|
+
drill: RESULT_SCHEMA,
|
|
165
|
+
search_knowledge: S.obj({ datasource: S.str, examples: S.arr({ type: 'object' }), glossary: S.arr({ type: 'object' }), metrics: S.arr({ type: 'object' }) }, ['datasource', 'examples', 'glossary', 'metrics']),
|
|
166
|
+
explain_change: S.obj({ summary: S.str, current: {}, baseline: {}, measure: S.str, additive: S.bool, dimensions: S.arr({ type: 'object' }) }, ['dimensions']),
|
|
167
|
+
list_metrics: S.obj({ datasource: S.str, metrics: S.arr(S.obj({ id: S.str, name: S.str, expression: S.str, table: S.str, filter: S.str, timeColumn: S.str, unit: S.str, description: S.str, dimensions: S.arr(S.str), synonyms: S.arr(S.str) }, ['name', 'expression'])) }, ['datasource', 'metrics']),
|
|
168
|
+
list_dashboards: S.obj({ dashboards: S.arr(S.obj({ id: S.str, name: S.str, description: S.str, widgets: S.arr(S.obj({ id: S.str, title: S.str, datasource: S.str, chart: S.str }, ['id', 'title'])), filters: S.arr({ type: 'object' }) }, ['id', 'name', 'widgets'])) }, ['dashboards']),
|
|
169
|
+
run_dashboard: S.obj({ dashboard: S.str, results: S.arr(S.obj({ widgetId: S.str, title: S.str, ok: S.bool, error: S.str, result: RESULT_SCHEMA }, ['widgetId', 'title', 'ok'])), skipped: S.arr(S.obj({ widgetId: S.str, title: S.str, reason: S.str }, ['widgetId', 'reason'])) }, ['dashboard', 'results']),
|
|
170
|
+
data_freshness: S.obj({ datasource: S.str, checkedAt: { type: ['integer', 'null'] }, staleCount: S.int, tables: S.arr(S.obj({ table: S.str, column: S.str, latestLocal: { type: ['string', 'null'] }, lagMinutes: { type: ['number', 'null'] }, thresholdHours: S.num, status: S.str }, ['table', 'status'])) }, ['datasource', 'staleCount', 'tables']),
|
|
171
|
+
explain_sql: S.obj({ datasource: S.str, dialect: S.str, admitted: S.bool, sql: S.str, head: S.str, refusal: S.str, plan: S.arr({ type: 'object' }), scans: S.arr(S.obj({ table: S.str, rows: S.num }, ['table'])), scanGuard: S.obj({ threshold: S.int, wouldRefuse: S.bool }, ['threshold', 'wouldRefuse']), rowLevelRules: S.arr(S.str), note: S.str }, ['datasource', 'admitted']),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Tool definitions for external models. Runtime-optional tools depend on the service. */
|
|
175
|
+
export function toolDefs(service) {
|
|
176
|
+
// https://modelcontextprotocol.io/specification/2025-03-26/server/tools (annotations, 2025-03-26)
|
|
177
|
+
const ann = (title, { idempotent = true } = {}) => ({ title, readOnlyHint: true, destructiveHint: false, idempotentHint: idempotent, openWorldHint: false })
|
|
72
178
|
const defs = [
|
|
73
179
|
{
|
|
74
180
|
name: 'list_datasources',
|
|
75
181
|
title: 'List data sources',
|
|
76
182
|
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
183
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
78
|
-
annotations: ro,
|
|
79
184
|
},
|
|
80
185
|
{
|
|
81
186
|
name: 'get_schema',
|
|
@@ -90,12 +195,11 @@ function toolDefs(service) {
|
|
|
90
195
|
},
|
|
91
196
|
additionalProperties: false,
|
|
92
197
|
},
|
|
93
|
-
annotations: ro,
|
|
94
198
|
},
|
|
95
199
|
{
|
|
96
200
|
name: 'query',
|
|
97
201
|
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.',
|
|
202
|
+
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 and as the resource nl2sql://result/{resultId}. Prefer aggregated queries.',
|
|
99
203
|
inputSchema: {
|
|
100
204
|
type: 'object',
|
|
101
205
|
properties: {
|
|
@@ -107,12 +211,12 @@ function toolDefs(service) {
|
|
|
107
211
|
required: ['sql'],
|
|
108
212
|
additionalProperties: false,
|
|
109
213
|
},
|
|
110
|
-
|
|
214
|
+
idempotent: false,
|
|
111
215
|
},
|
|
112
216
|
{
|
|
113
217
|
name: 'query_metric',
|
|
114
218
|
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
|
|
219
|
+
description: 'Query a metric from the metric library by name or synonym (see list_metrics). Its expression, table, filter and time column are fixed by the definition — do not re-derive them in SQL. Optional time grain, dimensions (allowed ones only), time range and filters.',
|
|
116
220
|
inputSchema: {
|
|
117
221
|
type: 'object',
|
|
118
222
|
properties: {
|
|
@@ -129,14 +233,13 @@ function toolDefs(service) {
|
|
|
129
233
|
required: ['metric'],
|
|
130
234
|
additionalProperties: false,
|
|
131
235
|
},
|
|
132
|
-
|
|
236
|
+
idempotent: false,
|
|
133
237
|
},
|
|
134
238
|
{
|
|
135
239
|
name: 'search_knowledge',
|
|
136
240
|
title: 'Search business knowledge',
|
|
137
241
|
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
242
|
inputSchema: { type: 'object', properties: { question: { type: 'string' }, datasource: DS_PARAM }, required: ['question'], additionalProperties: false },
|
|
139
|
-
annotations: ro,
|
|
140
243
|
},
|
|
141
244
|
{
|
|
142
245
|
name: 'drill',
|
|
@@ -153,7 +256,7 @@ function toolDefs(service) {
|
|
|
153
256
|
required: ['resultId', 'mode'],
|
|
154
257
|
additionalProperties: false,
|
|
155
258
|
},
|
|
156
|
-
|
|
259
|
+
idempotent: false,
|
|
157
260
|
},
|
|
158
261
|
{
|
|
159
262
|
name: 'explain_change',
|
|
@@ -170,9 +273,50 @@ function toolDefs(service) {
|
|
|
170
273
|
required: ['resultId'],
|
|
171
274
|
additionalProperties: false,
|
|
172
275
|
},
|
|
173
|
-
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'list_metrics',
|
|
279
|
+
title: 'List governed metrics',
|
|
280
|
+
description: 'List the metric library of a data source: name, synonyms, expression, table, filter, time column, unit and allowed dimensions. Use the names with query_metric.',
|
|
281
|
+
inputSchema: { type: 'object', properties: { datasource: DS_PARAM }, additionalProperties: false },
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: 'list_dashboards',
|
|
285
|
+
title: 'List dashboards',
|
|
286
|
+
description: 'List dashboards with their widgets (id, title, data source, chart type) and global filters. Only widgets on data sources this token may use are listed.',
|
|
287
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'run_dashboard',
|
|
291
|
+
title: 'Run a dashboard',
|
|
292
|
+
description: 'Run the widgets of a dashboard (governed SQL; viewers may use it) with optional global filters, e.g. [{"kind":"time","column":"created_at","range":"last7d"}] or [{"kind":"in","column":"status","values":["1"]}]. Returns per-widget results shaped by the privacy setting (rows / statistics / counts). Widgets on data sources outside this token\'s scope are skipped.',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
dashboard: { type: 'string', description: 'Dashboard id or name (list_dashboards).' },
|
|
297
|
+
widgets: { type: 'array', items: { type: 'string' }, description: 'Only these widget ids (default: all, at most 12).' },
|
|
298
|
+
filters: { type: 'array', items: { type: 'object', properties: { kind: { type: 'string', enum: ['time', 'in'] }, column: { type: 'string' }, range: { type: 'string' }, from: { type: 'string' }, to: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, required: ['kind', 'column'], additionalProperties: false }, description: 'Global filters replacing the dashboard\'s saved ones.' },
|
|
299
|
+
},
|
|
300
|
+
required: ['dashboard'],
|
|
301
|
+
additionalProperties: false,
|
|
302
|
+
},
|
|
303
|
+
idempotent: false,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'explain_sql',
|
|
307
|
+
title: 'Explain / dry-run SQL',
|
|
308
|
+
description: 'Dry-run admission of one SQL statement without returning rows: read-only admission, access policy and row-level rule checks, then the database EXPLAIN plan for SELECTs, with full scans and whether the scan guard would refuse it. Use it to check a statement before running query.',
|
|
309
|
+
inputSchema: { type: 'object', properties: { sql: { type: 'string' }, datasource: DS_PARAM }, required: ['sql'], additionalProperties: false },
|
|
174
310
|
},
|
|
175
311
|
]
|
|
312
|
+
if (typeof service.freshnessFor === 'function') {
|
|
313
|
+
defs.push({
|
|
314
|
+
name: 'data_freshness',
|
|
315
|
+
title: 'Data freshness',
|
|
316
|
+
description: 'How fresh the data of a data source is: per table the latest timestamp of its main time column, lag, stale threshold and status (ok / warn / stale / empty). Check it before answering questions about recent periods and warn when stale.',
|
|
317
|
+
inputSchema: { type: 'object', properties: { datasource: DS_PARAM, tables: { type: 'array', items: { type: 'string' }, description: 'Only these tables.' }, refresh: { type: 'boolean', description: 'Measure now instead of using the last check.' } }, additionalProperties: false },
|
|
318
|
+
})
|
|
319
|
+
}
|
|
176
320
|
if (typeof service.reconcile === 'function') {
|
|
177
321
|
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
322
|
const pair = { type: 'array', items: { type: 'object', properties: { left: { type: 'string' }, right: { type: 'string' }, tolerance: { type: 'number' }, relative: { type: 'boolean' } }, required: ['left'], additionalProperties: false } }
|
|
@@ -192,7 +336,7 @@ function toolDefs(service) {
|
|
|
192
336
|
required: ['left', 'right', 'keys'],
|
|
193
337
|
additionalProperties: false,
|
|
194
338
|
},
|
|
195
|
-
|
|
339
|
+
idempotent: false,
|
|
196
340
|
})
|
|
197
341
|
}
|
|
198
342
|
if (typeof service.forecast === 'function') {
|
|
@@ -201,21 +345,52 @@ function toolDefs(service) {
|
|
|
201
345
|
title: 'Forecast',
|
|
202
346
|
description: 'Forecast a time-series result (resultId from query / query_metric) a few periods ahead with the plugin\'s forecasting feature (read-only).',
|
|
203
347
|
inputSchema: { type: 'object', properties: { resultId: { type: 'string' }, horizon: { type: 'integer', minimum: 1, maximum: 90 } }, required: ['resultId'], additionalProperties: true },
|
|
204
|
-
annotations: ro,
|
|
205
348
|
})
|
|
206
349
|
}
|
|
207
|
-
return defs
|
|
350
|
+
return defs.map(({ idempotent, ...d }) => ({ ...d, annotations: ann(d.title, { idempotent: idempotent !== false }), ...(OUTPUT_SCHEMAS[d.name] ? { outputSchema: OUTPUT_SCHEMAS[d.name] } : {}) }))
|
|
208
351
|
}
|
|
209
352
|
|
|
210
|
-
|
|
353
|
+
/** Tool definitions shaped for a revision: no outputSchema before 2025-06-18, no annotations before 2025-03-26, no title before 2025-06-18. */
|
|
354
|
+
function toolsForVersion(defs, version) {
|
|
355
|
+
const f = revisionFeatures(version)
|
|
356
|
+
return defs.map(({ outputSchema, annotations, title, ...d }) => ({
|
|
357
|
+
...d,
|
|
358
|
+
...(f.titles ? { title } : {}),
|
|
359
|
+
...(f.annotations ? { annotations } : {}),
|
|
360
|
+
...(f.structuredContent && outputSchema ? { outputSchema } : {}),
|
|
361
|
+
}))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export const PROMPTS = [
|
|
211
365
|
{
|
|
212
366
|
name: 'ask_data',
|
|
213
367
|
title: 'Answer a data question',
|
|
214
368
|
description: 'Answer a business question from the governed databases.',
|
|
215
369
|
arguments: [{ name: 'question', description: 'The business question', required: true }, { name: 'datasource', description: 'Data source name (optional)', required: false }],
|
|
216
370
|
},
|
|
371
|
+
{
|
|
372
|
+
name: 'analyze_table',
|
|
373
|
+
title: 'Analyze a table',
|
|
374
|
+
description: 'Profile one table: what it holds, its key dimensions and measures, recent trend and data quality notes.',
|
|
375
|
+
arguments: [{ name: 'datasource', description: 'Data source name', required: true }, { name: 'table', description: 'Table name', required: true }],
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: 'compare_periods',
|
|
379
|
+
title: 'Compare two periods of a metric',
|
|
380
|
+
description: 'Compare a governed metric between the current and the previous period and explain what drove the change.',
|
|
381
|
+
arguments: [{ name: 'metric', description: 'Metric name from the metric library', required: true }, { name: 'period', description: 'day | week | month (default week)', required: false }, { name: 'datasource', description: 'Data source name (optional)', required: false }],
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: 'reconcile_sources',
|
|
385
|
+
title: 'Reconcile two sources',
|
|
386
|
+
description: 'Check that the same business figures agree between two data sources (or two queries).',
|
|
387
|
+
arguments: [{ name: 'left', description: 'What the left (reference) side is, e.g. "orders DB, daily paid amount"', required: true }, { name: 'right', description: 'What the right side is, e.g. "finance DB, daily settled amount"', required: true }],
|
|
388
|
+
},
|
|
217
389
|
]
|
|
218
390
|
|
|
391
|
+
/** Which completion source an argument uses. */
|
|
392
|
+
const COMPLETE_ARGS = { datasource: 'datasource', table: 'table', metric: 'metric', id: 'datasource', period: 'period' }
|
|
393
|
+
|
|
219
394
|
export const methods = {
|
|
220
395
|
/** state.mcp, initialized lazily. */
|
|
221
396
|
mcpState() {
|
|
@@ -235,7 +410,11 @@ export const methods = {
|
|
|
235
410
|
} else if (a.role) {
|
|
236
411
|
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
412
|
} else scope = { type: 'owner' }
|
|
238
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
id: tok.id, name: tok.name, prefix: tok.prefix, scope, createdAt: tok.createdAt, lastUsedAt: tok.lastUsedAt ?? null,
|
|
415
|
+
expiresAt: tok.expiresAt ?? null, expired: Boolean(tok.expiresAt && tok.expiresAt <= Date.now()),
|
|
416
|
+
tools: Array.isArray(tok.tools) ? tok.tools : null, rateLimit: tok.rateLimit ?? null, rotatedAt: tok.rotatedAt ?? null,
|
|
417
|
+
}
|
|
239
418
|
},
|
|
240
419
|
|
|
241
420
|
mcpStatus() {
|
|
@@ -247,6 +426,7 @@ export const methods = {
|
|
|
247
426
|
url: `http://127.0.0.1:${port}${MCP_PATH}`,
|
|
248
427
|
protocolVersions: MCP_VERSIONS,
|
|
249
428
|
tokens: s.tokens.map(t => this.mcpPublicToken(t)),
|
|
429
|
+
toolNames: toolDefs(this).map(t => t.name),
|
|
250
430
|
}
|
|
251
431
|
},
|
|
252
432
|
|
|
@@ -286,32 +466,111 @@ export const methods = {
|
|
|
286
466
|
return { kind: 'owner' }
|
|
287
467
|
},
|
|
288
468
|
|
|
469
|
+
/** Normalize the optional limits: expiresAt (ms, future or null), tools (allowlist of known names or null), rateLimit (requests/min or null). */
|
|
470
|
+
mcpNormalizeLimits(input, { partial = false } = {}) {
|
|
471
|
+
const out = {}
|
|
472
|
+
if (!partial || input.expiresAt !== undefined) {
|
|
473
|
+
const v = input.expiresAt
|
|
474
|
+
if (v === undefined || v === null || v === '' || v === 0) out.expiresAt = null
|
|
475
|
+
else {
|
|
476
|
+
const ms = typeof v === 'number' ? v : Date.parse(String(v))
|
|
477
|
+
if (!Number.isFinite(ms)) throw new UserError('nl2sql/invalid', '过期时间无效')
|
|
478
|
+
if (ms <= Date.now()) throw new UserError('nl2sql/invalid', '过期时间必须晚于现在')
|
|
479
|
+
out.expiresAt = Math.floor(ms)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (!partial || input.tools !== undefined) {
|
|
483
|
+
if (input.tools === undefined || input.tools === null) out.tools = null
|
|
484
|
+
else {
|
|
485
|
+
if (!Array.isArray(input.tools)) throw new UserError('nl2sql/invalid', 'tools 必须是工具名数组')
|
|
486
|
+
const known = new Set(toolDefs(this).map(t => t.name))
|
|
487
|
+
const bad = input.tools.filter(n => !known.has(n))
|
|
488
|
+
if (bad.length) throw new UserError('nl2sql/invalid', `未知工具:${bad.join(', ')}`)
|
|
489
|
+
if (!input.tools.length) throw new UserError('nl2sql/invalid', '请至少允许一个工具')
|
|
490
|
+
out.tools = [...new Set(input.tools.map(String))]
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!partial || input.rateLimit !== undefined) {
|
|
494
|
+
const v = input.rateLimit
|
|
495
|
+
out.rateLimit = v === undefined || v === null || v === '' || v === 0 ? null : clampInt(Number(v), 1, 10000, 'rateLimit')
|
|
496
|
+
}
|
|
497
|
+
return out
|
|
498
|
+
},
|
|
499
|
+
|
|
289
500
|
/** Create a token; the plain value is returned once and never stored. */
|
|
290
|
-
async createMcpToken({ name, scope } = {}) {
|
|
501
|
+
async createMcpToken({ name, scope, expiresAt, tools, rateLimit } = {}) {
|
|
291
502
|
const s = this.mcpState()
|
|
292
503
|
const tokenName = requireText(name, 'name', 60)
|
|
293
504
|
if (s.tokens.some(t => t.name === tokenName)) throw new UserError('nl2sql/duplicate', `令牌名称已存在:${tokenName}`)
|
|
294
505
|
if (s.tokens.length >= 50) throw new UserError('nl2sql/invalid', '令牌数量已达上限(50)')
|
|
295
506
|
const actor = this.mcpNormalizeScope(scope)
|
|
507
|
+
const limits = this.mcpNormalizeLimits({ expiresAt, tools, rateLimit })
|
|
296
508
|
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 }
|
|
509
|
+
const rec = { id: newId('mt'), name: tokenName, hash: hashToken(token), prefix: token.slice(0, 12), actor, createdAt: Date.now(), lastUsedAt: null, ...limits }
|
|
298
510
|
s.tokens.push(rec)
|
|
299
511
|
this.auditAdmin('mcp.token.create', `${tokenName}(${actor.memberId ? `member ${actor.memberId}` : actor.role ? `role ${actor.role}` : 'owner'})`)
|
|
300
512
|
await this.save()
|
|
301
513
|
return { ...this.mcpPublicToken(rec), token }
|
|
302
514
|
},
|
|
303
515
|
|
|
516
|
+
/** Update name / scope / allowlist / limits. Never re-reveals the secret. */
|
|
517
|
+
async updateMcpToken({ id, name, scope, expiresAt, tools, rateLimit } = {}) {
|
|
518
|
+
const s = this.mcpState()
|
|
519
|
+
const tok = s.tokens.find(t => t.id === id)
|
|
520
|
+
if (!tok) throw new UserError('nl2sql/invalid', '令牌不存在')
|
|
521
|
+
if (name !== undefined) {
|
|
522
|
+
const n = requireText(name, 'name', 60)
|
|
523
|
+
if (s.tokens.some(t => t.name === n && t.id !== id)) throw new UserError('nl2sql/duplicate', `令牌名称已存在:${n}`)
|
|
524
|
+
tok.name = n
|
|
525
|
+
}
|
|
526
|
+
if (scope !== undefined) tok.actor = this.mcpNormalizeScope(scope)
|
|
527
|
+
Object.assign(tok, this.mcpNormalizeLimits({ expiresAt, tools, rateLimit }, { partial: true }))
|
|
528
|
+
tok.updatedAt = Date.now()
|
|
529
|
+
this.auditAdmin('mcp.token.update', tok.name)
|
|
530
|
+
await this.save()
|
|
531
|
+
return this.mcpPublicToken(tok)
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
/** New secret for an existing token (shown once); the old one stops working at once. */
|
|
535
|
+
async rotateMcpToken({ id } = {}) {
|
|
536
|
+
const tok = this.mcpState().tokens.find(t => t.id === id)
|
|
537
|
+
if (!tok) throw new UserError('nl2sql/invalid', '令牌不存在')
|
|
538
|
+
const token = `n2s_mcp_${randomBytes(24).toString('base64url')}`
|
|
539
|
+
tok.hash = hashToken(token)
|
|
540
|
+
tok.prefix = token.slice(0, 12)
|
|
541
|
+
tok.rotatedAt = Date.now()
|
|
542
|
+
this.mcpDropToken(id)
|
|
543
|
+
this.auditAdmin('mcp.token.rotate', tok.name)
|
|
544
|
+
await this.save()
|
|
545
|
+
return { ...this.mcpPublicToken(tok), token }
|
|
546
|
+
},
|
|
547
|
+
|
|
304
548
|
async revokeMcpToken({ id } = {}) {
|
|
305
549
|
const s = this.mcpState()
|
|
306
550
|
const tok = s.tokens.find(t => t.id === id)
|
|
307
551
|
if (!tok) throw new UserError('nl2sql/invalid', '令牌不存在')
|
|
308
552
|
s.tokens = s.tokens.filter(t => t.id !== id)
|
|
309
|
-
|
|
553
|
+
this.mcpDropToken(id)
|
|
310
554
|
this.auditAdmin('mcp.token.revoke', tok.name)
|
|
311
555
|
await this.save()
|
|
312
556
|
return { revoked: id }
|
|
313
557
|
},
|
|
314
558
|
|
|
559
|
+
/** End sessions, streams and in-flight calls of a token (revoked / rotated). */
|
|
560
|
+
mcpDropToken(id) {
|
|
561
|
+
for (const [sid, sess] of this.mcpSessions ?? []) if (sess.tokenId === id) this.mcpEndSession(sid)
|
|
562
|
+
for (const l of [...(this.mcpListeners ?? [])]) if (l.tokenId === id) l.close()
|
|
563
|
+
for (const c of this.mcpInflight?.values() ?? []) if (c.tokenId === id) c.abort('token revoked')
|
|
564
|
+
this.mcpRate?.delete(id)
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
mcpEndSession(sid) {
|
|
568
|
+
const sess = this.mcpSessions?.get(sid)
|
|
569
|
+
if (!sess) return
|
|
570
|
+
this.mcpSessions.delete(sid)
|
|
571
|
+
sess.stream?.close()
|
|
572
|
+
},
|
|
573
|
+
|
|
315
574
|
/** The token record for a bearer value (constant-time hash comparison), or undefined. */
|
|
316
575
|
mcpFindToken(bearer) {
|
|
317
576
|
if (!bearer) return undefined
|
|
@@ -356,6 +615,12 @@ export const methods = {
|
|
|
356
615
|
return pick
|
|
357
616
|
},
|
|
358
617
|
|
|
618
|
+
/** Tools a token may use (allowlist ∩ installed). */
|
|
619
|
+
mcpToolsFor(tok) {
|
|
620
|
+
const all = toolDefs(this)
|
|
621
|
+
return Array.isArray(tok?.tools) ? all.filter(t => tok.tools.includes(t.name)) : all
|
|
622
|
+
},
|
|
623
|
+
|
|
359
624
|
/** The part of a result an external model may see, per the data source privacy settings. */
|
|
360
625
|
mcpResultView(result) {
|
|
361
626
|
const settings = this.settingsFor(this.findDatasource(result.datasourceId))
|
|
@@ -384,18 +649,30 @@ export const methods = {
|
|
|
384
649
|
} else {
|
|
385
650
|
view.note = 'The privacy setting shares no row data or statistics with external models; only the row count and columns are returned.'
|
|
386
651
|
}
|
|
387
|
-
return
|
|
652
|
+
return jsonSafe(view)
|
|
388
653
|
},
|
|
389
654
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
655
|
+
/** Warnings worth a notifications/message for a result view (truncation, redaction, masking). */
|
|
656
|
+
mcpResultWarnings(view) {
|
|
657
|
+
const w = []
|
|
658
|
+
if (!view || typeof view !== 'object') return w
|
|
659
|
+
if (view.truncated) w.push({ level: 'warning', data: { event: 'truncated', resultId: view.resultId, message: `Result truncated at ${view.rowCount} rows (row cap). Aggregate in SQL.` } })
|
|
660
|
+
if (view.privacy && view.privacy !== 'rows') w.push({ level: 'notice', data: { event: 'privacy-redaction', resultId: view.resultId, privacy: view.privacy, message: view.privacy === 'none' ? 'Rows and statistics withheld by the privacy setting.' : 'Rows withheld by the privacy setting; statistics returned.' } })
|
|
661
|
+
else if (view.rows && view.rowCount > (view.rowsReturned ?? 0)) w.push({ level: 'notice', data: { event: 'privacy-row-cap', resultId: view.resultId, message: view.note } })
|
|
662
|
+
if (view.columns?.some(c => c.masked)) w.push({ level: 'notice', data: { event: 'masked-columns', resultId: view.resultId, columns: view.columns.filter(c => c.masked).map(c => c.name) } })
|
|
663
|
+
return w
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
/** Run one MCP tool as `actor`. Returns a plain object (structuredContent). `ctx.log(level, data)` emits notifications/message. */
|
|
667
|
+
async mcpCallTool(name, args, actor, ctx = {}) {
|
|
392
668
|
const a = args && typeof args === 'object' ? args : {}
|
|
393
669
|
const source = 'mcp'
|
|
670
|
+
const log = ctx.log ?? (() => {})
|
|
394
671
|
switch (name) {
|
|
395
672
|
case 'list_datasources':
|
|
396
673
|
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,
|
|
674
|
+
datasources: this.mcpVisibleDatasources(actor).map(d => jsonSafe({
|
|
675
|
+
id: d.id, name: d.name, type: d.type ?? 'mysql', database: d.database ?? undefined, description: d.description || undefined,
|
|
399
676
|
dialect: DIALECTS[d.type ?? 'mysql']?.sqlName, serverVersion: d.serverVersion || undefined,
|
|
400
677
|
})),
|
|
401
678
|
}
|
|
@@ -403,22 +680,24 @@ export const methods = {
|
|
|
403
680
|
const ds = this.mcpDatasource(actor, a.datasource)
|
|
404
681
|
const schema = await this.visibleSchema(ds)
|
|
405
682
|
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.
|
|
683
|
+
return { datasource: ds.name, tableCount: schema.tables.length, text: this.mcpSchemaText(schema, ds, tables, typeof a.question === 'string' ? a.question : undefined) }
|
|
407
684
|
}
|
|
408
685
|
case 'query': {
|
|
409
686
|
if (typeof a.sql !== 'string' || !a.sql.trim()) throw new UserError('nl2sql/invalid', '`sql` is required')
|
|
410
687
|
const ds = this.mcpDatasource(actor, a.datasource)
|
|
688
|
+
ctx.ds = ds
|
|
411
689
|
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
690
|
return this.mcpResultView(r)
|
|
413
691
|
}
|
|
414
692
|
case 'query_metric': {
|
|
415
693
|
const ds = this.mcpDatasource(actor, a.datasource)
|
|
694
|
+
ctx.ds = ds
|
|
416
695
|
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
696
|
return { ...this.mcpResultView(r), metric: r.metric }
|
|
418
697
|
}
|
|
419
698
|
case 'search_knowledge': {
|
|
420
699
|
const ds = this.mcpDatasource(actor, a.datasource)
|
|
421
|
-
return { datasource: ds.name, ...this.searchKnowledge(ds.id, String(a.question ?? '')) }
|
|
700
|
+
return jsonSafe({ datasource: ds.name, ...this.searchKnowledge(ds.id, String(a.question ?? '')) })
|
|
422
701
|
}
|
|
423
702
|
case 'drill': {
|
|
424
703
|
if (!['breakdown', 'detail'].includes(a.mode)) throw new UserError('nl2sql/invalid', 'mode must be breakdown or detail')
|
|
@@ -427,19 +706,62 @@ export const methods = {
|
|
|
427
706
|
}
|
|
428
707
|
case 'explain_change': {
|
|
429
708
|
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 })) }
|
|
709
|
+
return jsonSafe({ 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 })) })
|
|
710
|
+
}
|
|
711
|
+
case 'list_metrics': {
|
|
712
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
713
|
+
return { datasource: ds.name, metrics: this.mcpMetrics(ds) }
|
|
714
|
+
}
|
|
715
|
+
case 'list_dashboards':
|
|
716
|
+
return { dashboards: this.mcpDashboards(actor) }
|
|
717
|
+
case 'run_dashboard': {
|
|
718
|
+
const d = this.mcpFindDashboard(actor, a.dashboard)
|
|
719
|
+
const only = Array.isArray(a.widgets) ? a.widgets.map(String) : null
|
|
720
|
+
const filters = Array.isArray(a.filters) ? a.filters : undefined
|
|
721
|
+
const results = []
|
|
722
|
+
const skipped = []
|
|
723
|
+
const widgets = d.widgets.filter(w => !only || only.includes(w.id))
|
|
724
|
+
for (const w of widgets) {
|
|
725
|
+
if (results.length >= 12) { skipped.push({ widgetId: w.id, title: w.title, reason: 'limit of 12 widgets per call' }); continue }
|
|
726
|
+
const ds = this.findDatasource(w.datasourceId)
|
|
727
|
+
if (!ds || !this.mcpVisibleDatasources(actor).includes(ds)) { skipped.push({ widgetId: w.id, title: w.title, reason: 'data source outside this token\'s scope' }); continue }
|
|
728
|
+
if (ctx.signal?.aborted) break
|
|
729
|
+
ctx.phase?.('widget', { title: w.title, index: results.length + 1, total: Math.min(widgets.length, 12) })
|
|
730
|
+
try {
|
|
731
|
+
const r = await this.runQuery({ governed: true, sql: w.sql, datasourceId: ds.id, title: `${d.name} · ${w.title}`, chart: w.chart, source, confirmScan: true, filters: filters ?? d.filters ?? [], useCache: true, actor })
|
|
732
|
+
results.push({ widgetId: w.id, title: w.title, ok: true, result: this.mcpResultView(r) })
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (error.code === 'query/cancelled') throw error
|
|
735
|
+
results.push({ widgetId: w.id, title: w.title, ok: false, error: error.message })
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return jsonSafe({ dashboard: d.name, results, ...(skipped.length ? { skipped } : {}) })
|
|
431
739
|
}
|
|
740
|
+
case 'data_freshness': {
|
|
741
|
+
if (typeof this.freshnessFor !== 'function') throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
742
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
743
|
+
const view = await this.freshnessFor({ datasourceId: ds.id, refresh: Boolean(a.refresh) })
|
|
744
|
+
const schema = await this.cachedVisibleSchema(ds)
|
|
745
|
+
const visible = schema ? new Set(schema.tables.map(t => t.name)) : null
|
|
746
|
+
const only = Array.isArray(a.tables) ? new Set(a.tables.map(String)) : null
|
|
747
|
+
const tables = view.tables.filter(t => (!visible || visible.has(t.table)) && (!only || only.has(t.table))).map(t => jsonSafe({ table: t.table, column: t.column, latestLocal: t.latestLocal ?? null, lagMinutes: t.lagMinutes ?? null, thresholdHours: t.thresholdHours, status: t.status }))
|
|
748
|
+
const out = { datasource: ds.name, checkedAt: view.checkedAt ?? null, staleCount: tables.filter(t => t.status === 'stale').length, tables }
|
|
749
|
+
if (out.staleCount) log('warning', { event: 'stale-data', datasource: ds.name, tables: tables.filter(t => t.status === 'stale').map(t => t.table), message: `${out.staleCount} table(s) are stale; recent figures may be incomplete.` })
|
|
750
|
+
return out
|
|
751
|
+
}
|
|
752
|
+
case 'explain_sql':
|
|
753
|
+
return this.mcpExplainSql(a, actor)
|
|
432
754
|
case 'reconcile': {
|
|
433
755
|
if (typeof this.reconcile !== 'function') throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
434
756
|
this.assertCanWriteSql(actor, 'write reconciliation SQL')
|
|
435
757
|
const sideOf = (s) => ({ sql: s?.sql, datasourceId: this.mcpDatasource(actor, s?.datasource).id })
|
|
436
758
|
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
759
|
const privacy = [out.left.datasourceId, out.right.datasourceId].map(id => this.settingsFor(this.findDatasource(id)).resultToModel)
|
|
438
|
-
return
|
|
760
|
+
return jsonSafe({
|
|
439
761
|
title: out.title, left: out.left, right: out.right, summary: out.summary, warnings: out.warnings,
|
|
440
762
|
resultIds: Object.fromEntries(out.results.map(r => [r.reconcilePart, r.resultId])),
|
|
441
763
|
firstDiffs: privacy.every(p => p === 'rows') ? out.diffs.slice(0, 20) : `hidden by privacy settings (${privacy.join('/')})`,
|
|
442
|
-
})
|
|
764
|
+
})
|
|
443
765
|
}
|
|
444
766
|
case 'forecast': {
|
|
445
767
|
if (typeof this[name] !== 'function') throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
@@ -447,15 +769,381 @@ export const methods = {
|
|
|
447
769
|
const input = { ...a, actor, source }
|
|
448
770
|
if (a.datasource) { const ds = this.mcpDatasource(actor, a.datasource); input.datasourceId = ds.id }
|
|
449
771
|
const out = await this[name](input)
|
|
450
|
-
return out?.rows && out?.columns && out?.resultId ? this.mcpResultView(out) :
|
|
772
|
+
return out?.rows && out?.columns && out?.resultId ? this.mcpResultView(out) : jsonSafe(out)
|
|
451
773
|
}
|
|
452
774
|
default:
|
|
453
775
|
throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
454
776
|
}
|
|
455
777
|
},
|
|
456
778
|
|
|
457
|
-
|
|
458
|
-
|
|
779
|
+
mcpSchemaText(schema, ds, tables, question) {
|
|
780
|
+
return this.schemaForModel(schema, ds, tables, { question }).replace(/nl2sql_schema/g, 'get_schema').replace(/nl2sql_knowledge action=search/g, 'search_knowledge')
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
mcpMetrics(ds) {
|
|
784
|
+
return this.knowledgeFor(ds.id).metrics.map(m => jsonSafe({ id: m.id, name: m.name, expression: m.expression, table: m.table || undefined, filter: m.filter || undefined, timeColumn: m.timeColumn || undefined, unit: m.unit || undefined, description: m.description || undefined, dimensions: m.dimensions ?? [], synonyms: m.synonyms ?? [] }))
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
/** Dashboards with only the widgets this actor may see; dashboards with no visible widget are hidden for scoped tokens. */
|
|
788
|
+
mcpDashboards(actor) {
|
|
789
|
+
const visible = new Set(this.mcpVisibleDatasources(actor).map(d => d.id))
|
|
790
|
+
return (this.state.dashboards ?? []).map(d => {
|
|
791
|
+
const widgets = d.widgets.filter(w => visible.has(w.datasourceId)).map(w => ({ id: w.id, title: w.title, datasource: this.findDatasource(w.datasourceId)?.name ?? w.datasourceId, chart: w.chart?.type ?? 'table' }))
|
|
792
|
+
return { id: d.id, name: d.name, description: d.description || undefined, widgets, filters: d.filters ?? [], hiddenWidgets: d.widgets.length - widgets.length }
|
|
793
|
+
}).filter(d => !this.mcpScoped(actor) || d.widgets.length).map(({ hiddenWidgets: _h, ...d }) => jsonSafe(d))
|
|
794
|
+
},
|
|
795
|
+
|
|
796
|
+
mcpFindDashboard(actor, ref) {
|
|
797
|
+
const d = (this.state.dashboards ?? []).find(x => x.id === ref) ?? (this.state.dashboards ?? []).find(x => x.name === ref)
|
|
798
|
+
if (!d || !this.mcpDashboards(actor).some(x => x.id === d.id)) throw new UserError('nl2sql/unknown-dashboard', `Dashboard not found: ${ref}`)
|
|
799
|
+
return d
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
/** explain_sql: admission + policy + row rules + EXPLAIN, never rows. Reuses service.explain()'s driver facility. */
|
|
803
|
+
async mcpExplainSql(a, actor) {
|
|
804
|
+
if (typeof a.sql !== 'string' || !a.sql.trim()) throw new UserError('nl2sql/invalid', '`sql` is required')
|
|
805
|
+
const ds = this.mcpDatasource(actor, a.datasource)
|
|
806
|
+
const dialect = ds.type ?? 'mysql'
|
|
807
|
+
const out = { datasource: ds.name, dialect, admitted: false }
|
|
808
|
+
let admitted
|
|
809
|
+
try { admitted = admitSql(a.sql, { dialect }) } catch (error) { return { ...out, refusal: error.message } }
|
|
810
|
+
if (actor?.role === 'viewer' && !this.isVerifiedExample(ds, a.sql)) return { ...out, refusal: 'viewer tokens can only run governed SQL (metrics, verified examples, dashboards)' }
|
|
811
|
+
const schema = await this.analyzeSchema(ds.id)
|
|
812
|
+
const denied = checkAccess(admitted.sql, this.policyFor(ds), schema, admitted.head, { dialect })
|
|
813
|
+
if (denied) return { ...out, refusal: denied }
|
|
814
|
+
const rules = this.rowRulesFor(actor, ds)
|
|
815
|
+
Object.assign(out, { admitted: true, sql: admitted.sql, head: admitted.head, ...(rules.length ? { rowLevelRules: rules.map(r => `${r.column} IN (${r.values.join(', ')})`) } : {}) })
|
|
816
|
+
if (admitted.head !== 'SELECT' || dialect === 'elasticsearch') return { ...out, note: 'No execution plan for this statement type / engine; admission checks passed.' }
|
|
817
|
+
try {
|
|
818
|
+
const res = await this.withDriver(ds, d => d.explain(admitted.sql, { timeoutMs: Math.min(this.settingsFor(ds).timeoutMs, 10000) }))
|
|
819
|
+
const threshold = Number(this.settingsFor(ds).scanGuardRows ?? 0)
|
|
820
|
+
return jsonSafe({ ...out, plan: (res.plan ?? []).slice(0, 50), scans: res.scans ?? [], scanGuard: { threshold, wouldRefuse: threshold > 0 && (res.scans ?? []).some(s => s.rows >= threshold) }, ...(rules.length ? { note: 'Row-level rules are injected when the statement runs; nested queries are then refused.' } : {}) })
|
|
821
|
+
} catch (error) {
|
|
822
|
+
return { ...out, note: `EXPLAIN failed: ${error.message}` }
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
// ------------------------------------------------------------------ resources
|
|
827
|
+
|
|
828
|
+
/** Static resources visible to an actor (resources/list). */
|
|
829
|
+
mcpResources(actor) {
|
|
830
|
+
const out = []
|
|
831
|
+
for (const d of this.mcpVisibleDatasources(actor)) {
|
|
832
|
+
const id = encodeURIComponent(d.id)
|
|
833
|
+
const lm = d.schemaAnalyzedAt ? { annotations: { lastModified: new Date(d.schemaAnalyzedAt).toISOString() } } : {}
|
|
834
|
+
out.push({ uri: `nl2sql://datasource/${id}/schema`, name: `${d.name} schema`, title: `${d.name} — schema overview`, description: `Visible tables, columns, relations and dialect notes of ${d.name} (${DIALECTS[d.type ?? 'mysql']?.label ?? d.type}).`, mimeType: 'text/plain', ...lm })
|
|
835
|
+
out.push({ uri: `nl2sql://datasource/${id}/knowledge`, name: `${d.name} knowledge`, title: `${d.name} — business knowledge`, description: 'Glossary, metric definitions, verified question→SQL examples and table / column notes (visible tables only).', mimeType: 'application/json' })
|
|
836
|
+
out.push({ uri: `nl2sql://datasource/${id}/metrics`, name: `${d.name} metrics`, title: `${d.name} — metric library`, description: 'Governed metric definitions usable with query_metric.', mimeType: 'application/json' })
|
|
837
|
+
if (typeof this.freshnessFor === 'function') out.push({ uri: `nl2sql://datasource/${id}/freshness`, name: `${d.name} freshness`, title: `${d.name} — data freshness`, description: 'Latest timestamp, lag and stale status per table.', mimeType: 'application/json' })
|
|
838
|
+
}
|
|
839
|
+
out.push({ uri: 'nl2sql://dashboards', name: 'dashboards', title: 'Dashboards', description: 'Dashboards and their widgets visible to this token.', mimeType: 'application/json' })
|
|
840
|
+
for (const d of this.mcpDashboards(actor)) out.push({ uri: `nl2sql://dashboard/${encodeURIComponent(d.id)}`, name: `dashboard ${d.name}`, title: `Dashboard — ${d.name}`, description: `${d.widgets.length} widget(s)`, mimeType: 'application/json' })
|
|
841
|
+
return out
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
mcpResourceTemplates() {
|
|
845
|
+
return [
|
|
846
|
+
{ uriTemplate: 'nl2sql://datasource/{id}/schema', name: 'Data source schema', description: 'Schema overview of a data source', mimeType: 'text/plain' },
|
|
847
|
+
{ uriTemplate: 'nl2sql://datasource/{id}/table/{table}', name: 'Table schema', description: 'Full description of one table', mimeType: 'text/plain' },
|
|
848
|
+
{ uriTemplate: 'nl2sql://datasource/{id}/knowledge', name: 'Business knowledge', description: 'Glossary, metrics, verified examples, notes', mimeType: 'application/json' },
|
|
849
|
+
{ uriTemplate: 'nl2sql://datasource/{id}/metrics', name: 'Metric library', description: 'Governed metric definitions', mimeType: 'application/json' },
|
|
850
|
+
...(typeof this.freshnessFor === 'function' ? [{ uriTemplate: 'nl2sql://datasource/{id}/freshness', name: 'Data freshness', description: 'Freshness per table', mimeType: 'application/json' }] : []),
|
|
851
|
+
{ uriTemplate: 'nl2sql://dashboard/{id}', name: 'Dashboard', description: 'A dashboard and its widgets (definitions, not data)', mimeType: 'application/json' },
|
|
852
|
+
{ uriTemplate: 'nl2sql://result/{resultId}', name: 'Query result', description: `A query result as CSV (first ${CSV_MAX_ROWS} rows; only when the privacy setting shares rows)`, mimeType: 'text/csv' },
|
|
853
|
+
]
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
/** resources/read. Returns { contents } or throws RpcError(not found). */
|
|
857
|
+
async mcpReadResource(uri, actor, notFoundCode) {
|
|
858
|
+
const notFound = () => new RpcError(notFoundCode, 'Resource not found', { uri })
|
|
859
|
+
const json = (value) => ({ contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(value, null, 1) }] })
|
|
860
|
+
let m = /^nl2sql:\/\/result\/([^/]+)$/.exec(uri)
|
|
861
|
+
if (m) {
|
|
862
|
+
const r = this.lookupResult(decodeURIComponent(m[1]))
|
|
863
|
+
if (!r) throw notFound()
|
|
864
|
+
try { this.assertResultAccess(r, actor) } catch { throw notFound() }
|
|
865
|
+
const settings = this.settingsFor(this.findDatasource(r.datasourceId))
|
|
866
|
+
const privacy = settings.resultToModel ?? 'summary'
|
|
867
|
+
if (privacy !== 'rows') {
|
|
868
|
+
const view = this.mcpResultView(r)
|
|
869
|
+
const text = `# privacy=${privacy}: rows are not shared with external models\r\n${toCsv(['column', 'kind', 'masked'].map(name => ({ name })), view.columns.map(c => [c.name, c.kind, c.masked ? 'yes' : '']))}`
|
|
870
|
+
return { contents: [{ uri, mimeType: 'text/csv', text }], warnings: [{ level: 'notice', data: { event: 'privacy-redaction', resultId: r.resultId, privacy } }] }
|
|
871
|
+
}
|
|
872
|
+
const cap = Math.min(CSV_MAX_ROWS, settings.modelRows ?? 20)
|
|
873
|
+
let rows = r.rows.slice(0, cap)
|
|
874
|
+
let text = toCsv(r.columns, rows)
|
|
875
|
+
while (Buffer.byteLength(text) > CSV_MAX_BYTES && rows.length > 1) { rows = rows.slice(0, Math.floor(rows.length / 2)); text = toCsv(r.columns, rows) }
|
|
876
|
+
const warnings = rows.length < r.rowCount ? [{ level: 'notice', data: { event: 'truncated', resultId: r.resultId, message: `CSV holds ${rows.length} of ${r.rowCount} rows (privacy row cap / size cap).` } }] : []
|
|
877
|
+
return { contents: [{ uri, mimeType: 'text/csv', text: jsonSafe(text) }], warnings }
|
|
878
|
+
}
|
|
879
|
+
if (uri === 'nl2sql://dashboards') return json({ dashboards: this.mcpDashboards(actor) })
|
|
880
|
+
m = /^nl2sql:\/\/dashboard\/([^/]+)$/.exec(uri)
|
|
881
|
+
if (m) {
|
|
882
|
+
const d = this.mcpDashboards(actor).find(x => x.id === decodeURIComponent(m[1]))
|
|
883
|
+
if (!d) throw notFound()
|
|
884
|
+
return json(d)
|
|
885
|
+
}
|
|
886
|
+
m = /^nl2sql:\/\/datasource\/([^/]+)\/(schema|knowledge|metrics|freshness|table\/(.+))$/.exec(uri)
|
|
887
|
+
if (!m) throw notFound()
|
|
888
|
+
let ds
|
|
889
|
+
try { ds = this.mcpDatasource(actor, decodeURIComponent(m[1])) } catch { throw notFound() }
|
|
890
|
+
const kind = m[2].startsWith('table/') ? 'table' : m[2]
|
|
891
|
+
if (kind === 'metrics') return json({ datasource: ds.name, metrics: this.mcpMetrics(ds) })
|
|
892
|
+
if (kind === 'freshness') {
|
|
893
|
+
if (typeof this.freshnessFor !== 'function') throw notFound()
|
|
894
|
+
return json(await this.mcpCallTool('data_freshness', { datasource: ds.id }, actor))
|
|
895
|
+
}
|
|
896
|
+
const schema = await this.visibleSchema(ds)
|
|
897
|
+
if (kind === 'knowledge') return json(this.mcpKnowledge(ds, schema, actor))
|
|
898
|
+
const table = m[3] ? decodeURIComponent(m[3]) : undefined
|
|
899
|
+
if (table && !schema.tables.some(t => t.name.toLowerCase() === table.toLowerCase())) throw notFound()
|
|
900
|
+
return { contents: [{ uri, mimeType: 'text/plain', text: this.mcpSchemaText(schema, ds, table ? [table] : undefined) }] }
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
/** Knowledge filtered to what the token may see: notes only for visible tables / columns; viewers see examples (governed SQL). */
|
|
904
|
+
mcpKnowledge(ds, schema, _actor) {
|
|
905
|
+
const k = this.knowledgeFor(ds.id)
|
|
906
|
+
const visible = new Map(schema.tables.map(t => [t.name, new Set(t.columns.map(c => c.name))]))
|
|
907
|
+
const notes = Object.fromEntries(Object.entries(k.notes ?? {}).filter(([key]) => {
|
|
908
|
+
if (visible.has(key)) return true
|
|
909
|
+
const i = key.lastIndexOf('.')
|
|
910
|
+
return i > 0 && visible.get(key.slice(0, i))?.has(key.slice(i + 1))
|
|
911
|
+
}))
|
|
912
|
+
const tablesOk = (sql) => { try { return !checkAccess(sql, this.policyFor(ds), schema, 'SELECT', { dialect: ds.type ?? 'mysql' }) } catch { return false } }
|
|
913
|
+
return jsonSafe({
|
|
914
|
+
datasource: ds.name,
|
|
915
|
+
glossary: k.glossary.map(g => ({ term: g.term, synonyms: g.synonyms, definition: g.definition, ...(g.sql ? { sql: g.sql } : {}) })),
|
|
916
|
+
metrics: this.mcpMetrics(ds),
|
|
917
|
+
examples: k.examples.filter(e => tablesOk(e.sql)).slice(-100).map(e => ({ question: e.question, sql: e.sql, ...(e.note ? { note: e.note } : {}) })),
|
|
918
|
+
notes,
|
|
919
|
+
})
|
|
920
|
+
},
|
|
921
|
+
|
|
922
|
+
// ------------------------------------------------------------------ completion
|
|
923
|
+
|
|
924
|
+
/** completion/complete values for prompt / resource-template arguments, scoped to the actor. */
|
|
925
|
+
async mcpComplete(params, actor) {
|
|
926
|
+
const ref = params.ref ?? {}
|
|
927
|
+
const arg = params.argument ?? {}
|
|
928
|
+
const name = String(arg.name ?? '')
|
|
929
|
+
const value = String(arg.value ?? '').toLowerCase()
|
|
930
|
+
const context = params.context?.arguments ?? {}
|
|
931
|
+
if (ref.type === 'ref/prompt') {
|
|
932
|
+
const p = PROMPTS.find(x => x.name === ref.name)
|
|
933
|
+
if (!p) throw new RpcError(-32602, `Unknown prompt: ${ref.name}`)
|
|
934
|
+
if (!p.arguments.some(x => x.name === name)) throw new RpcError(-32602, `Unknown argument: ${name}`)
|
|
935
|
+
} else if (ref.type === 'ref/resource') {
|
|
936
|
+
if (!this.mcpResourceTemplates().some(t => t.uriTemplate === ref.uri) && !String(ref.uri ?? '').startsWith('nl2sql://')) throw new RpcError(-32602, `Unknown resource template: ${ref.uri}`)
|
|
937
|
+
} else throw new RpcError(-32602, 'ref.type must be ref/prompt or ref/resource')
|
|
938
|
+
const kind = COMPLETE_ARGS[name]
|
|
939
|
+
const visible = this.mcpVisibleDatasources(actor)
|
|
940
|
+
const dsFromCtx = () => {
|
|
941
|
+
const r = context.datasource ?? context.id
|
|
942
|
+
try { return this.mcpDatasource(actor, r) } catch { return undefined }
|
|
943
|
+
}
|
|
944
|
+
let candidates = []
|
|
945
|
+
if (kind === 'datasource') candidates = visible.map(d => (name === 'id' ? d.id : d.name))
|
|
946
|
+
else if (kind === 'table') { const ds = dsFromCtx(); if (ds) { const s = await this.cachedVisibleSchema(ds); candidates = s ? s.tables.map(t => t.name) : [] } }
|
|
947
|
+
else if (kind === 'metric') { const dss = context.datasource ? [dsFromCtx()].filter(Boolean) : visible; candidates = [...new Set(dss.flatMap(d => this.knowledgeFor(d.id).metrics.flatMap(m => [m.name, ...(m.synonyms ?? [])])))] }
|
|
948
|
+
else if (kind === 'period') candidates = ['day', 'week', 'month']
|
|
949
|
+
const starts = candidates.filter(c => c.toLowerCase().startsWith(value))
|
|
950
|
+
const contains = candidates.filter(c => !c.toLowerCase().startsWith(value) && c.toLowerCase().includes(value))
|
|
951
|
+
const all = [...starts, ...contains]
|
|
952
|
+
return { completion: { values: all.slice(0, 100), total: all.length, hasMore: all.length > 100 } }
|
|
953
|
+
},
|
|
954
|
+
|
|
955
|
+
// ------------------------------------------------------------------ prompts
|
|
956
|
+
|
|
957
|
+
mcpPrompt(name, args = {}) {
|
|
958
|
+
const p = PROMPTS.find(x => x.name === name)
|
|
959
|
+
if (!p) throw new RpcError(-32602, `Unknown prompt: ${name}`)
|
|
960
|
+
const v = (k) => String(args?.[k] ?? '').trim()
|
|
961
|
+
for (const a of p.arguments) if (a.required && !v(a.name)) throw new RpcError(-32602, `Missing required argument: ${a.name}`)
|
|
962
|
+
const msg = (text) => ({ description: p.description, messages: [{ role: 'user', content: { type: 'text', text } }] })
|
|
963
|
+
if (name === 'ask_data') {
|
|
964
|
+
const dsName = v('datasource') ? ` on the data source "${v('datasource')}"` : ''
|
|
965
|
+
return msg(`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: ${v('question')}`)
|
|
966
|
+
}
|
|
967
|
+
if (name === 'analyze_table') {
|
|
968
|
+
return msg(`Analyze the table "${v('table')}" on the data source "${v('datasource')}" with the nl2sql tools:\n1. get_schema with tables=["${v('table')}"] — describe what one row represents, the key dimensions (enum / category columns), measures and the main time column.\n2. data_freshness (if available) — note whether the table is stale.\n3. query — a few aggregated read-only statements: row count, rows per day/week over the last 30 days on the time column, and the distribution of the top 1–2 dimensions. Use explain_sql first if unsure about cost.\n4. Summarize: purpose, size, trend, notable distributions, data quality concerns (nulls, gaps), and 3 good questions to ask next.\nNever select raw sensitive columns; aggregate instead.`)
|
|
969
|
+
}
|
|
970
|
+
if (name === 'compare_periods') {
|
|
971
|
+
const period = ['day', 'week', 'month'].includes(v('period')) ? v('period') : 'week'
|
|
972
|
+
const dsName = v('datasource') ? ` on the data source "${v('datasource')}"` : ''
|
|
973
|
+
return msg(`Compare the metric "${v('metric')}"${dsName} between the current ${period} and the previous ${period}:\n1. list_metrics — confirm the metric and its allowed dimensions.\n2. query_metric with grain="${period === 'month' ? 'month' : 'day'}" over the last two ${period}s (from / to dates), then aggregate the two periods.\n3. explain_change on that resultId (current vs baseline) to rank the dimensions that explain the change.\n4. Report: both values, absolute and % change, the top drivers, and caveats (incomplete current ${period}, stale data via data_freshness).`)
|
|
974
|
+
}
|
|
975
|
+
return msg(`Reconcile these two sources:\n- Left (reference): ${v('left')}\n- Right: ${v('right')}\n\nSteps: list_datasources → get_schema on both → write one aggregated read-only query per side at the same grain (e.g. per day, or per order id) with matching key and value column names → reconcile with keys and compare columns (set a numeric tolerance when rounding differs) → explain where the sources disagree (keys only on one side, value mismatches, totals) and likely causes (timing, filters, currency, status mapping).`)
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
// ------------------------------------------------------------------ call log, rate limit
|
|
979
|
+
|
|
980
|
+
/** Sliding-window per-token rate limit. Returns 0 when allowed, else seconds to wait. */
|
|
981
|
+
mcpRateCheck(tok, now = Date.now()) {
|
|
982
|
+
if (!tok.rateLimit) return 0
|
|
983
|
+
this.mcpRate ??= new Map()
|
|
984
|
+
const hits = (this.mcpRate.get(tok.id) ?? []).filter(t => now - t < 60000)
|
|
985
|
+
if (hits.length >= tok.rateLimit) {
|
|
986
|
+
this.mcpRate.set(tok.id, hits)
|
|
987
|
+
return Math.max(1, Math.ceil((hits[0] + 60000 - now) / 1000))
|
|
988
|
+
}
|
|
989
|
+
hits.push(now)
|
|
990
|
+
this.mcpRate.set(tok.id, hits)
|
|
991
|
+
return 0
|
|
992
|
+
},
|
|
993
|
+
|
|
994
|
+
/** One call-log record (no result rows; SQL capped at 2000 chars). */
|
|
995
|
+
mcpLogCall(tok, { method, tool, datasource, ms, status, rows, error, sql, version }) {
|
|
996
|
+
try {
|
|
997
|
+
this.records.put('mcpCall', {
|
|
998
|
+
id: newId('mc'), at: Date.now(), tokenId: tok.id, tokenName: tok.name, method, ...(tool ? { tool } : {}),
|
|
999
|
+
...(datasource ? { datasource } : {}), ms, status, ...(rows !== undefined ? { rows } : {}),
|
|
1000
|
+
...(error ? { error: String(error).slice(0, 300) } : {}), ...(sql ? { sql: String(sql).slice(0, 2000) } : {}), ...(version ? { version } : {}),
|
|
1001
|
+
})
|
|
1002
|
+
} catch (e) { console.warn('nl2sql mcp: call log failed: %s', e.message) }
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
mcpCallLog({ tokenId, limit = 100, status, tool } = {}) {
|
|
1006
|
+
const n = Math.max(1, Math.min(1000, Number(limit) || 100))
|
|
1007
|
+
return this.records.list('mcpCall', { ref: tokenId || undefined, status: status || undefined, limit: n, filter: tool ? (r => r.tool === tool || r.method === tool) : undefined })
|
|
1008
|
+
},
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Test connection: discover + tools/list (+ legacy initialize) against the running server over
|
|
1012
|
+
* loopback with a temporary in-memory credential (never persisted, removed afterwards).
|
|
1013
|
+
*/
|
|
1014
|
+
async mcpSelfTest() {
|
|
1015
|
+
const st = this.mcpStatus()
|
|
1016
|
+
if (!st.running) return { ok: false, error: this.mcpError ?? 'MCP server is not running', url: st.url, steps: [] }
|
|
1017
|
+
const token = `n2s_mcp_selftest_${randomBytes(18).toString('base64url')}`
|
|
1018
|
+
const temp = { id: newId('mt-selftest'), name: '(self-test)', hash: hashToken(token), prefix: token.slice(0, 12), actor: { kind: 'owner' }, createdAt: Date.now(), temporary: true }
|
|
1019
|
+
this.mcpTempTokens ??= new Set()
|
|
1020
|
+
this.mcpTempTokens.add(temp)
|
|
1021
|
+
const steps = []
|
|
1022
|
+
const post = async (body, extra = {}) => {
|
|
1023
|
+
const t0 = Date.now()
|
|
1024
|
+
const res = await fetch(st.url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${token}`, ...extra }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000) })
|
|
1025
|
+
const json = await res.json().catch(() => null)
|
|
1026
|
+
return { status: res.status, json, ms: Date.now() - t0, headers: res.headers }
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const meta = { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {}, 'io.modelcontextprotocol/clientInfo': { name: 'nl2sql-self-test', version: SERVER_INFO.version } }
|
|
1030
|
+
const d = await post({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: meta } }, { 'mcp-protocol-version': '2026-07-28', 'mcp-method': 'server/discover' })
|
|
1031
|
+
steps.push({ step: 'server/discover', ok: d.status === 200 && Boolean(d.json?.result), ms: d.ms, detail: d.json?.result ? `versions ${d.json.result.supportedVersions.join(', ')}` : d.json?.error?.message ?? `HTTP ${d.status}` })
|
|
1032
|
+
const tl = await post({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: meta } }, { 'mcp-protocol-version': '2026-07-28', 'mcp-method': 'tools/list' })
|
|
1033
|
+
const tools = tl.json?.result?.tools?.map(t => t.name) ?? []
|
|
1034
|
+
steps.push({ step: 'tools/list', ok: tl.status === 200 && tools.length > 0, ms: tl.ms, detail: `${tools.length} tools` })
|
|
1035
|
+
const init = await post({ jsonrpc: '2.0', id: 3, method: 'initialize', params: { protocolVersion: LATEST_LEGACY, capabilities: {}, clientInfo: { name: 'nl2sql-self-test', version: SERVER_INFO.version } } })
|
|
1036
|
+
const sid = init.headers.get('mcp-session-id')
|
|
1037
|
+
steps.push({ step: `initialize (${LATEST_LEGACY})`, ok: init.status === 200 && init.json?.result?.protocolVersion === LATEST_LEGACY, ms: init.ms, detail: init.json?.result ? `session ${sid ? 'minted' : 'missing'}` : init.json?.error?.message ?? `HTTP ${init.status}` })
|
|
1038
|
+
if (sid) this.mcpEndSession(sid)
|
|
1039
|
+
return { ok: steps.every(s => s.ok), url: st.url, server: d.json?.result?._meta?.['io.modelcontextprotocol/serverInfo'] ?? SERVER_INFO, tools, steps }
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
return { ok: false, url: st.url, error: error.message, steps }
|
|
1042
|
+
} finally {
|
|
1043
|
+
this.mcpTempTokens.delete(temp)
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
// ------------------------------------------------------------------ notifications (server → client)
|
|
1048
|
+
|
|
1049
|
+
/** Send a notification to every legacy session stream / modern listener that opted in. */
|
|
1050
|
+
mcpBroadcast(kind, payload) {
|
|
1051
|
+
// kind: 'resources/list_changed' | 'resources/updated'
|
|
1052
|
+
for (const sess of this.mcpSessions?.values() ?? []) {
|
|
1053
|
+
if (!sess.stream) continue
|
|
1054
|
+
const tok = this.mcpState().tokens.find(t => t.id === sess.tokenId)
|
|
1055
|
+
const actor = tok && this.mcpActor(tok)
|
|
1056
|
+
if (!actor) continue
|
|
1057
|
+
if (kind === 'resources/list_changed') sess.stream.send({ jsonrpc: '2.0', method: 'notifications/resources/list_changed' })
|
|
1058
|
+
else if (sess.subscriptions?.has(payload.uri) && this.mcpCanSee(actor, payload.uri)) sess.stream.send({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: payload.uri } })
|
|
1059
|
+
}
|
|
1060
|
+
for (const l of this.mcpListeners ?? []) {
|
|
1061
|
+
const tok = this.mcpState().tokens.find(t => t.id === l.tokenId)
|
|
1062
|
+
const actor = tok && this.mcpActor(tok)
|
|
1063
|
+
if (!actor) continue
|
|
1064
|
+
const meta = { 'io.modelcontextprotocol/subscriptionId': l.id }
|
|
1065
|
+
if (kind === 'resources/list_changed' && l.filter.resourcesListChanged) l.send({ jsonrpc: '2.0', method: 'notifications/resources/list_changed', params: { _meta: meta } })
|
|
1066
|
+
else if (kind === 'resources/updated' && l.filter.resourceSubscriptions?.includes(payload.uri) && this.mcpCanSee(actor, payload.uri)) l.send({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { _meta: meta, uri: payload.uri } })
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
|
|
1070
|
+
mcpCanSee(actor, uri) {
|
|
1071
|
+
const m = /^nl2sql:\/\/datasource\/([^/]+)\//.exec(uri)
|
|
1072
|
+
if (!m) return true
|
|
1073
|
+
const ds = this.findDatasource(decodeURIComponent(m[1]))
|
|
1074
|
+
return Boolean(ds && this.mcpVisibleDatasources(actor).includes(ds))
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Cheap watcher: compares data source ids (→ list_changed) and schemaAnalyzedAt / knowledge
|
|
1079
|
+
* fingerprints (→ updated for schema / table / knowledge / metrics resources). Runs only while
|
|
1080
|
+
* someone listens. The interval is unref'd.
|
|
1081
|
+
*/
|
|
1082
|
+
mcpWatchTick() {
|
|
1083
|
+
const ds = this.state.datasources
|
|
1084
|
+
const ids = ds.map(d => d.id).join('|')
|
|
1085
|
+
const prev = this.mcpWatchState
|
|
1086
|
+
const next = { ids, at: Object.fromEntries(ds.map(d => [d.id, d.schemaAnalyzedAt ?? 0])), know: Object.fromEntries(ds.map(d => [d.id, createHash('sha1').update(JSON.stringify(this.state.knowledge?.[d.id] ?? {})).digest('hex')])), dash: createHash('sha1').update(JSON.stringify((this.state.dashboards ?? []).map(d => [d.id, d.widgets.map(w => w.id)]))).digest('hex') }
|
|
1087
|
+
this.mcpWatchState = next
|
|
1088
|
+
if (!prev) return
|
|
1089
|
+
if (prev.ids !== ids || prev.dash !== next.dash) this.mcpBroadcast('resources/list_changed')
|
|
1090
|
+
for (const d of ds) {
|
|
1091
|
+
const id = encodeURIComponent(d.id)
|
|
1092
|
+
if (prev.at[d.id] !== undefined && prev.at[d.id] !== next.at[d.id]) {
|
|
1093
|
+
this.mcpBroadcast('resources/updated', { uri: `nl2sql://datasource/${id}/schema` })
|
|
1094
|
+
for (const uri of this.mcpSubscribedUris()) if (uri.startsWith(`nl2sql://datasource/${id}/table/`)) this.mcpBroadcast('resources/updated', { uri })
|
|
1095
|
+
}
|
|
1096
|
+
if (prev.know[d.id] !== undefined && prev.know[d.id] !== next.know[d.id]) {
|
|
1097
|
+
this.mcpBroadcast('resources/updated', { uri: `nl2sql://datasource/${id}/knowledge` })
|
|
1098
|
+
this.mcpBroadcast('resources/updated', { uri: `nl2sql://datasource/${id}/metrics` })
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
mcpSubscribedUris() {
|
|
1104
|
+
const out = new Set()
|
|
1105
|
+
for (const s of this.mcpSessions?.values() ?? []) for (const u of s.subscriptions ?? []) out.add(u)
|
|
1106
|
+
for (const l of this.mcpListeners ?? []) for (const u of l.filter.resourceSubscriptions ?? []) out.add(u)
|
|
1107
|
+
return out
|
|
1108
|
+
},
|
|
1109
|
+
|
|
1110
|
+
mcpEnsureWatcher() {
|
|
1111
|
+
const listening = (this.mcpListeners?.size ?? 0) + [...(this.mcpSessions?.values() ?? [])].filter(s => s.stream).length
|
|
1112
|
+
if (listening && !this.mcpWatchTimer) {
|
|
1113
|
+
this.mcpWatchState = undefined
|
|
1114
|
+
this.mcpWatchTick()
|
|
1115
|
+
this.mcpWatchTimer = setInterval(() => { try { this.mcpWatchTick() } catch (e) { console.warn('nl2sql mcp watch: %s', e.message) } }, this.mcpWatchMs ?? WATCH_MS)
|
|
1116
|
+
this.mcpWatchTimer.unref?.()
|
|
1117
|
+
} else if (!listening && this.mcpWatchTimer) {
|
|
1118
|
+
clearInterval(this.mcpWatchTimer)
|
|
1119
|
+
this.mcpWatchTimer = undefined
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
|
|
1123
|
+
/** Open an SSE response with heartbeats. Returns { send(obj), comment(), close(), closed }. */
|
|
1124
|
+
mcpOpenSse(res, headers = {}, onClose) {
|
|
1125
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no', ...headers })
|
|
1126
|
+
res.flushHeaders?.()
|
|
1127
|
+
const stream = {
|
|
1128
|
+
closed: false,
|
|
1129
|
+
send(obj) { if (!stream.closed) res.write(`event: message\ndata: ${JSON.stringify(obj)}\n\n`) },
|
|
1130
|
+
comment(text = '') { if (!stream.closed) res.write(`: ${text}\n\n`) },
|
|
1131
|
+
close() { if (stream.closed) return; stream.closed = true; clearInterval(hb); try { res.end() } catch {} onClose?.() },
|
|
1132
|
+
}
|
|
1133
|
+
const hb = setInterval(() => stream.comment('keep-alive'), this.mcpHeartbeatMs ?? HEARTBEAT_MS)
|
|
1134
|
+
hb.unref?.()
|
|
1135
|
+
res.on('close', () => { if (!stream.closed) { stream.closed = true; clearInterval(hb); onClose?.('client') } })
|
|
1136
|
+
return stream
|
|
1137
|
+
},
|
|
1138
|
+
|
|
1139
|
+
// ------------------------------------------------------------------ JSON-RPC
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Handle one JSON-RPC message. Returns { status, body?, headers? }.
|
|
1143
|
+
* ctx: { tok, actor, headers, session, stream?: { progress(), log() }, signal, version }.
|
|
1144
|
+
*/
|
|
1145
|
+
async mcpHandleMessage(msg, ctx) {
|
|
1146
|
+
const { tok, actor, headers, session } = ctx
|
|
459
1147
|
const isObj = msg && typeof msg === 'object' && !Array.isArray(msg)
|
|
460
1148
|
if (!isObj || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
461
1149
|
// A JSON-RPC response from the client (legacy) is accepted and ignored.
|
|
@@ -469,8 +1157,17 @@ export const methods = {
|
|
|
469
1157
|
const bodyVersion = meta['io.modelcontextprotocol/protocolVersion']
|
|
470
1158
|
const headerVersion = headers['mcp-protocol-version']
|
|
471
1159
|
const modern = Boolean(bodyVersion) && msg.method !== 'initialize'
|
|
1160
|
+
// Negotiated version: modern body version; legacy session version; MCP-Protocol-Version header.
|
|
1161
|
+
// Without any of these the transport spec suggests assuming 2025-03-26; we keep that era's
|
|
1162
|
+
// transport rules but still return the additive fields of the latest legacy revision
|
|
1163
|
+
// (structuredContent, resource_link, titles), which older clients ignore.
|
|
1164
|
+
const version = modern ? bodyVersion : session?.version ?? (MCP_VERSIONS.includes(headerVersion) ? headerVersion : LATEST_LEGACY)
|
|
1165
|
+
const feat = revisionFeatures(version)
|
|
1166
|
+
ctx.version = version
|
|
472
1167
|
const reply = (result, extra = {}) => ({ status: 200, body: { jsonrpc: '2.0', id, result: { ...(modern ? { _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO } } : {}), ...result, resultType: 'complete' } }, ...extra })
|
|
473
1168
|
const fail = (code, message, data, status = 200) => ({ status, body: { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } } })
|
|
1169
|
+
// Resource-not-found code: -32602 from 2026-07-28, -32002 before (basic/index error codes).
|
|
1170
|
+
const notFoundCode = modern ? -32602 : -32002
|
|
474
1171
|
|
|
475
1172
|
// Version checks: modern _meta version, header/body agreement, header version support.
|
|
476
1173
|
if (modern) {
|
|
@@ -479,78 +1176,117 @@ export const methods = {
|
|
|
479
1176
|
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
1177
|
const bodyName = params.name ?? params.uri
|
|
481
1178
|
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)
|
|
1179
|
+
const lvl = meta['io.modelcontextprotocol/logLevel']
|
|
1180
|
+
if (lvl !== undefined && !LOG_LEVELS.includes(lvl)) return fail(-32602, `Invalid log level: ${lvl}`, undefined, 400)
|
|
482
1181
|
} else if (headerVersion && msg.method !== 'initialize' && !MCP_VERSIONS.includes(headerVersion)) {
|
|
483
1182
|
return fail(-32022, 'Unsupported protocol version', { supported: MCP_VERSIONS, requested: headerVersion }, 400)
|
|
484
1183
|
}
|
|
485
1184
|
|
|
486
|
-
if (isNotification)
|
|
1185
|
+
if (isNotification) {
|
|
1186
|
+
// https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
|
|
1187
|
+
if (msg.method === 'notifications/cancelled' && params.requestId !== undefined) this.mcpCancel(tok, session, params.requestId, params.reason)
|
|
1188
|
+
return { status: 202 } // notifications/initialized, cancelled, …
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Logging: the effective level for this request (modern: _meta; legacy: logging/setLevel on the session).
|
|
1192
|
+
ctx.logLevel = modern ? meta['io.modelcontextprotocol/logLevel'] : session?.logLevel
|
|
1193
|
+
const log = (level, data) => {
|
|
1194
|
+
if (!ctx.logLevel || LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(ctx.logLevel)) return
|
|
1195
|
+
const n = { jsonrpc: '2.0', method: 'notifications/message', params: { level, logger: 'nl2sql', data } }
|
|
1196
|
+
if (ctx.stream) ctx.stream.send(n)
|
|
1197
|
+
else if (!modern && session?.stream) session.stream.send(n) // legacy: no request stream → standalone GET stream
|
|
1198
|
+
}
|
|
487
1199
|
|
|
488
1200
|
try {
|
|
489
1201
|
switch (msg.method) {
|
|
490
1202
|
case 'initialize': {
|
|
491
1203
|
const requested = String(params.protocolVersion ?? '')
|
|
492
1204
|
// Legacy handshake: echo a supported legacy version, else offer our latest legacy one.
|
|
493
|
-
const
|
|
1205
|
+
const v = MCP_VERSIONS.includes(requested) && !MODERN.has(requested) ? requested : LATEST_LEGACY
|
|
1206
|
+
const f = revisionFeatures(v)
|
|
494
1207
|
const sid = randomBytes(16).toString('hex')
|
|
495
1208
|
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.
|
|
498
|
-
return reply({ protocolVersion:
|
|
1209
|
+
this.mcpSessions.set(sid, { tokenId: tok.id, version: v, at: Date.now(), client: params.clientInfo?.name, subscriptions: new Set(), logLevel: undefined, stream: null })
|
|
1210
|
+
if (this.mcpSessions.size > 500) this.mcpEndSession(this.mcpSessions.keys().next().value)
|
|
1211
|
+
return reply({ protocolVersion: v, capabilities: { tools: { listChanged: false }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: false }, logging: {}, ...(f.completions ? { completions: {} } : {}) }, serverInfo: SERVER_INFO, instructions: INSTRUCTIONS }, { headers: { 'mcp-session-id': sid } })
|
|
499
1212
|
}
|
|
500
1213
|
case 'server/discover':
|
|
501
|
-
return reply({ supportedVersions: MCP_VERSIONS, capabilities: { tools: {}, resources: {}, prompts: {} }, _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO }, instructions: INSTRUCTIONS })
|
|
1214
|
+
return reply({ supportedVersions: MCP_VERSIONS, capabilities: { tools: { listChanged: false }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: false }, completions: {}, logging: {} }, _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO }, instructions: INSTRUCTIONS })
|
|
502
1215
|
case 'ping':
|
|
503
1216
|
return reply({})
|
|
504
|
-
case 'tools/list':
|
|
505
|
-
|
|
1217
|
+
case 'tools/list': {
|
|
1218
|
+
const { page, nextCursor } = paginate(toolsForVersion(this.mcpToolsFor(tok), version), params.cursor, 'tools', this.mcpPageSize?.tools ?? PAGE_SIZE.tools)
|
|
1219
|
+
return reply({ tools: page, ...(nextCursor ? { nextCursor } : {}) })
|
|
1220
|
+
}
|
|
506
1221
|
case 'tools/call': {
|
|
507
1222
|
const name = String(params.name ?? '')
|
|
508
1223
|
if (!toolDefs(this).some(t => t.name === name)) return fail(-32602, `Unknown tool: ${name}`)
|
|
1224
|
+
if (!this.mcpToolsFor(tok).some(t => t.name === name)) return fail(-32602, `Tool not allowed for this token: ${name}`)
|
|
1225
|
+
ctx.toolName = name
|
|
1226
|
+
const tctx = { log, signal: ctx.signal, phase: ctx.phase }
|
|
509
1227
|
try {
|
|
510
|
-
const value = await this.mcpCallTool(name, params.arguments ?? {}, actor)
|
|
1228
|
+
const value = await callContext.run({ signal: ctx.signal, phase: ctx.phase }, () => this.mcpCallTool(name, params.arguments ?? {}, actor, tctx))
|
|
1229
|
+
ctx.callInfo = { datasource: tctx.ds?.name ?? value?.datasource, rows: value?.rowCount, sql: value?.sql }
|
|
1230
|
+
for (const w of this.mcpResultWarnings(value)) log(w.level, w.data)
|
|
1231
|
+
for (const r of value?.results ?? []) for (const w of this.mcpResultWarnings(r.result)) log(w.level, w.data)
|
|
511
1232
|
const text = typeof value?.text === 'string' && Object.keys(value).length <= 3 ? value.text : JSON.stringify(value, null, 1)
|
|
512
|
-
|
|
1233
|
+
const content = [{ type: 'text', text }]
|
|
1234
|
+
if (feat.resourceLinks) {
|
|
1235
|
+
const ids = [value?.resultId, ...(value?.results ?? []).map(r => r.result?.resultId), ...Object.values(value?.resultIds ?? {})].filter(Boolean)
|
|
1236
|
+
for (const rid of [...new Set(ids)].slice(0, 12)) content.push({ type: 'resource_link', uri: `nl2sql://result/${encodeURIComponent(rid)}`, name: `result ${rid}`, description: 'Query result as CSV (subject to the privacy setting)', mimeType: 'text/csv' })
|
|
1237
|
+
}
|
|
1238
|
+
return reply({ content, ...(feat.structuredContent ? { structuredContent: value && typeof value === 'object' && !Array.isArray(value) ? value : { value } } : {}), isError: false })
|
|
513
1239
|
} catch (error) {
|
|
514
1240
|
if (error instanceof RpcError) throw error
|
|
1241
|
+
if (ctx.signal?.aborted || error.code === 'query/cancelled') { ctx.cancelled = true; return { status: 499, cancelled: true } }
|
|
1242
|
+
ctx.callError = error.message
|
|
515
1243
|
// Tool execution errors go back to the model as results so it can correct itself.
|
|
516
1244
|
return reply({ content: [{ type: 'text', text: error.message ?? String(error) }], isError: true })
|
|
517
1245
|
}
|
|
518
1246
|
}
|
|
519
|
-
case 'resources/list':
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
})
|
|
526
|
-
|
|
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' }] })
|
|
1247
|
+
case 'resources/list': {
|
|
1248
|
+
const { page, nextCursor } = paginate(this.mcpResources(actor), params.cursor, 'resources', this.mcpPageSize?.resources ?? PAGE_SIZE.resources)
|
|
1249
|
+
return reply({ resources: page, ...(nextCursor ? { nextCursor } : {}) })
|
|
1250
|
+
}
|
|
1251
|
+
case 'resources/templates/list': {
|
|
1252
|
+
const { page, nextCursor } = paginate(this.mcpResourceTemplates(), params.cursor, 'templates', this.mcpPageSize?.templates ?? PAGE_SIZE.templates)
|
|
1253
|
+
return reply({ resourceTemplates: page, ...(nextCursor ? { nextCursor } : {}) })
|
|
1254
|
+
}
|
|
528
1255
|
case 'resources/read': {
|
|
529
1256
|
const uri = String(params.uri ?? '')
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
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 }] })
|
|
1257
|
+
const out = await this.mcpReadResource(uri, actor, notFoundCode)
|
|
1258
|
+
for (const w of out.warnings ?? []) log(w.level, w.data)
|
|
1259
|
+
return reply({ contents: out.contents })
|
|
539
1260
|
}
|
|
540
1261
|
case 'resources/subscribe':
|
|
541
|
-
case 'resources/unsubscribe':
|
|
1262
|
+
case 'resources/unsubscribe': {
|
|
1263
|
+
// Legacy only (2026-07-28 replaced it with subscriptions/listen): per session.
|
|
1264
|
+
if (modern) return fail(-32601, `Method not found: ${msg.method} (use subscriptions/listen)`, undefined, 404)
|
|
1265
|
+
const uri = String(params.uri ?? '')
|
|
1266
|
+
if (!uri.startsWith('nl2sql://')) return fail(notFoundCode, 'Resource not found', { uri })
|
|
1267
|
+
if (!session) return fail(-32600, 'resources/subscribe needs an Mcp-Session-Id (initialize first)')
|
|
1268
|
+
if (msg.method === 'resources/subscribe') {
|
|
1269
|
+
if (!this.mcpCanSee(actor, uri)) return fail(notFoundCode, 'Resource not found', { uri })
|
|
1270
|
+
session.subscriptions.add(uri)
|
|
1271
|
+
} else session.subscriptions.delete(uri)
|
|
1272
|
+
this.mcpEnsureWatcher()
|
|
542
1273
|
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
1274
|
}
|
|
552
|
-
case '
|
|
1275
|
+
case 'prompts/list': {
|
|
1276
|
+
const { page, nextCursor } = paginate(PROMPTS.map(p => (feat.titles ? p : (({ title: _t, ...x }) => x)(p))), params.cursor, 'prompts', PAGE_SIZE.prompts)
|
|
1277
|
+
return reply({ prompts: page, ...(nextCursor ? { nextCursor } : {}) })
|
|
1278
|
+
}
|
|
1279
|
+
case 'prompts/get':
|
|
1280
|
+
return reply(this.mcpPrompt(params.name, params.arguments ?? {}))
|
|
1281
|
+
case 'completion/complete':
|
|
1282
|
+
if (!feat.completions) return fail(-32601, 'Method not found: completion/complete')
|
|
1283
|
+
return reply(await this.mcpComplete(params, actor))
|
|
1284
|
+
case 'logging/setLevel': {
|
|
1285
|
+
if (!feat.setLevel) return fail(-32601, 'Method not found: logging/setLevel (use _meta io.modelcontextprotocol/logLevel)', undefined, 404)
|
|
1286
|
+
if (!LOG_LEVELS.includes(params.level)) return fail(-32602, `Invalid log level: ${params.level}`)
|
|
1287
|
+
if (session) session.logLevel = params.level
|
|
553
1288
|
return reply({})
|
|
1289
|
+
}
|
|
554
1290
|
default:
|
|
555
1291
|
return fail(-32601, `Method not found: ${msg.method}`, undefined, modern ? 404 : 200)
|
|
556
1292
|
}
|
|
@@ -560,10 +1296,19 @@ export const methods = {
|
|
|
560
1296
|
}
|
|
561
1297
|
},
|
|
562
1298
|
|
|
1299
|
+
/** notifications/cancelled: abort the in-flight request of the same token (and session) — legacy and stdio clients. */
|
|
1300
|
+
mcpCancel(tok, session, requestId, reason) {
|
|
1301
|
+
for (const c of this.mcpInflight?.values() ?? []) {
|
|
1302
|
+
if (c.tokenId === tok.id && c.requestId === requestId && (!session || !c.session || c.session === session)) c.abort(reason ?? 'cancelled by client')
|
|
1303
|
+
}
|
|
1304
|
+
// A listen stream is also cancelled by referencing its request id (stdio rule; harmless on HTTP).
|
|
1305
|
+
for (const l of [...(this.mcpListeners ?? [])]) if (l.tokenId === tok.id && l.id === requestId) l.close()
|
|
1306
|
+
},
|
|
1307
|
+
|
|
563
1308
|
/** node:http request handler for the MCP endpoint. */
|
|
564
1309
|
async mcpHandleHttp(req, res) {
|
|
565
1310
|
const send = (status, body, headers = {}) => {
|
|
566
|
-
if (res.headersSent) return
|
|
1311
|
+
if (res.headersSent || res.destroyed) return
|
|
567
1312
|
const h = { 'cache-control': 'no-store', ...headers }
|
|
568
1313
|
if (body === undefined) { res.writeHead(status, h); res.end(); return }
|
|
569
1314
|
const text = JSON.stringify(body)
|
|
@@ -576,13 +1321,35 @@ export const methods = {
|
|
|
576
1321
|
if (!isLocalOrigin(req.headers.origin)) return send(403, rpcErr(-32600, 'Forbidden: Origin not allowed'))
|
|
577
1322
|
const path = (req.url ?? '/').split('?')[0]
|
|
578
1323
|
if (path !== MCP_PATH) return send(404, rpcErr(-32600, `Not found; the MCP endpoint is ${MCP_PATH}`))
|
|
579
|
-
if (
|
|
1324
|
+
if (!['POST', 'GET', 'DELETE'].includes(req.method)) return send(405, rpcErr(-32600, 'Method Not Allowed'), { allow: 'POST, GET, DELETE' })
|
|
580
1325
|
// Authentication.
|
|
581
1326
|
const auth = String(req.headers.authorization ?? '')
|
|
582
1327
|
const bearer = /^Bearer\s+(\S+)$/i.exec(auth)?.[1]
|
|
583
|
-
const tok = this.mcpFindToken(bearer)
|
|
1328
|
+
const tok = this.mcpFindToken(bearer) ?? (bearer ? [...(this.mcpTempTokens ?? [])].find(t => t.hash === hashToken(bearer)) : undefined)
|
|
1329
|
+
const wwwAuth = { 'www-authenticate': 'Bearer realm="nl2sql-mcp"' }
|
|
1330
|
+
if (tok?.expiresAt && tok.expiresAt <= Date.now()) return send(401, rpcErr(-32001, `Unauthorized: token "${tok.name}" expired at ${new Date(tok.expiresAt).toISOString()}; ask the administrator to extend it or create a new one`), { ...wwwAuth, 'www-authenticate': 'Bearer realm="nl2sql-mcp", error="invalid_token", error_description="token expired"' })
|
|
584
1331
|
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'),
|
|
1332
|
+
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'), wwwAuth)
|
|
1333
|
+
// Rate limit (per token, requests per minute).
|
|
1334
|
+
const wait = this.mcpRateCheck(tok)
|
|
1335
|
+
if (wait) {
|
|
1336
|
+
this.mcpLogCall(tok, { method: req.method === 'POST' ? 'rpc' : req.method, ms: 0, status: 'rate-limited' })
|
|
1337
|
+
return send(429, rpcErr(-32000, `Too Many Requests: rate limit ${tok.rateLimit}/min for token "${tok.name}"; retry after ${wait}s`), { 'retry-after': String(wait) })
|
|
1338
|
+
}
|
|
1339
|
+
const sid = req.headers['mcp-session-id']
|
|
1340
|
+
// Legacy GET stream / DELETE session (2025-03-26 … 2025-11-25). 2026-07-28 has neither → 405.
|
|
1341
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
1342
|
+
const session = sid ? this.mcpSessions?.get(String(sid)) : undefined
|
|
1343
|
+
if (!sid || MODERN.has(req.headers['mcp-protocol-version'])) return send(405, rpcErr(-32600, 'Method Not Allowed: use POST (and subscriptions/listen for notifications)'), { allow: 'POST' })
|
|
1344
|
+
if (!session || session.tokenId !== tok.id) return send(404, rpcErr(-32001, 'Session not found; initialize again'))
|
|
1345
|
+
if (req.method === 'DELETE') { this.mcpEndSession(String(sid)); return send(204) }
|
|
1346
|
+
if (!acceptsSse(req.headers.accept)) return send(406, rpcErr(-32600, 'Not Acceptable: GET needs Accept: text/event-stream'))
|
|
1347
|
+
session.stream?.close()
|
|
1348
|
+
session.stream = this.mcpOpenSse(res, {}, () => { if (session.stream?.closed) session.stream = null; this.mcpEnsureWatcher() })
|
|
1349
|
+
session.stream.comment('stream open')
|
|
1350
|
+
this.mcpEnsureWatcher()
|
|
1351
|
+
return
|
|
1352
|
+
}
|
|
586
1353
|
// Body.
|
|
587
1354
|
let size = 0
|
|
588
1355
|
const chunks = []
|
|
@@ -596,32 +1363,124 @@ export const methods = {
|
|
|
596
1363
|
let msg
|
|
597
1364
|
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return send(400, rpcErr(-32700, 'Parse error')) }
|
|
598
1365
|
// Legacy sessions: an unknown or foreign Mcp-Session-Id is a terminated session (404).
|
|
599
|
-
|
|
1366
|
+
// 2026-07-28 requests ignore the header (streamable-http "Backward Compatibility").
|
|
600
1367
|
let session
|
|
601
|
-
|
|
1368
|
+
const first = Array.isArray(msg) ? msg[0] : msg
|
|
1369
|
+
const isModernMsg = Boolean(first?.params?._meta?.['io.modelcontextprotocol/protocolVersion']) && first?.method !== 'initialize'
|
|
1370
|
+
if (sid && !isModernMsg && !(Array.isArray(msg) ? msg : [msg]).some(m => m?.method === 'initialize')) {
|
|
602
1371
|
session = this.mcpSessions?.get(String(sid))
|
|
603
1372
|
if (!session || session.tokenId !== tok.id) return send(404, rpcErr(-32001, 'Session not found; initialize again'))
|
|
604
1373
|
}
|
|
605
|
-
// lastUsedAt, persisted at most once a minute per token.
|
|
1374
|
+
// lastUsedAt, persisted at most once a minute per token (never for temporary self-test credentials).
|
|
606
1375
|
const now = Date.now()
|
|
607
|
-
if (!tok.lastUsedAt || now - tok.lastUsedAt > 60000) { tok.lastUsedAt = now; this.save() } else tok.lastUsedAt = now
|
|
1376
|
+
if (!tok.temporary) { if (!tok.lastUsedAt || now - tok.lastUsedAt > 60000) { tok.lastUsedAt = now; this.save() } else tok.lastUsedAt = now }
|
|
608
1377
|
const ctx = { tok, actor, headers: req.headers, session }
|
|
609
1378
|
if (Array.isArray(msg)) {
|
|
610
|
-
// JSON-RPC batch (2025-03-26): answer every request in one array.
|
|
1379
|
+
// JSON-RPC batch (2025-03-26 only; removed in 2025-06-18): answer every request in one array.
|
|
611
1380
|
if (!msg.length) return send(400, rpcErr(-32600, 'Invalid Request'))
|
|
612
1381
|
const out = []
|
|
613
1382
|
let extra = {}
|
|
614
1383
|
for (const m of msg.slice(0, 50)) {
|
|
615
|
-
const r = await this.mcpHandleMessage(m, ctx)
|
|
1384
|
+
const r = await this.mcpHandleMessage(m, { ...ctx })
|
|
616
1385
|
if (r.body) out.push(r.body)
|
|
617
1386
|
if (r.headers) extra = { ...extra, ...r.headers }
|
|
618
1387
|
}
|
|
619
1388
|
return out.length ? send(200, out, extra) : send(202)
|
|
620
1389
|
}
|
|
621
|
-
|
|
1390
|
+
if (msg?.method === 'subscriptions/listen') return this.mcpListen(msg, ctx, req, res, send)
|
|
1391
|
+
return this.mcpDispatch(msg, ctx, req, res, send)
|
|
1392
|
+
},
|
|
1393
|
+
|
|
1394
|
+
/** One POST message: plain JSON, or a request-scoped SSE stream for long requests when accepted. */
|
|
1395
|
+
async mcpDispatch(msg, ctx, req, res, send) {
|
|
1396
|
+
const isRequest = msg && typeof msg === 'object' && 'id' in msg && typeof msg.method === 'string'
|
|
1397
|
+
const started = Date.now()
|
|
1398
|
+
const params = msg?.params && typeof msg.params === 'object' ? msg.params : {}
|
|
1399
|
+
const progressToken = params._meta?.progressToken
|
|
1400
|
+
const modernMsg = Boolean(params._meta?.['io.modelcontextprotocol/protocolVersion'])
|
|
1401
|
+
const logLevel = modernMsg ? params._meta?.['io.modelcontextprotocol/logLevel'] : ctx.session?.logLevel
|
|
1402
|
+
// Streaming is used for long requests (tools/call) when the client accepts SSE (streamable-http "Sending Messages" 6).
|
|
1403
|
+
const stream = isRequest && msg.method === 'tools/call' && acceptsSse(req.headers.accept) && (progressToken !== undefined || logLevel || this.mcpAlwaysStream)
|
|
1404
|
+
const controller = new AbortController()
|
|
1405
|
+
ctx.signal = controller.signal
|
|
1406
|
+
let sse
|
|
1407
|
+
let inflight
|
|
1408
|
+
if (isRequest && msg.method === 'tools/call') {
|
|
1409
|
+
this.mcpInflight ??= new Map()
|
|
1410
|
+
inflight = { tokenId: ctx.tok.id, requestId: msg.id, session: ctx.session, abort: (reason) => { if (!controller.signal.aborted) controller.abort(reason) } }
|
|
1411
|
+
this.mcpInflight.set(inflight, inflight)
|
|
1412
|
+
// 2026-07-28: closing the stream is the cancellation signal. Legacy: disconnection SHOULD NOT be treated as cancel,
|
|
1413
|
+
// but no response can be delivered any more, so the work is stopped as well.
|
|
1414
|
+
res.on('close', () => { if (!res.writableFinished) inflight.abort('client disconnected') })
|
|
1415
|
+
}
|
|
1416
|
+
if (stream) {
|
|
1417
|
+
sse = this.mcpOpenSse(res)
|
|
1418
|
+
ctx.stream = sse
|
|
1419
|
+
let progress = 0
|
|
1420
|
+
const steps = { admitted: 'admitted', running: 'running query', fetched: 'fetched rows', formatting: 'formatting', widget: 'running widget' }
|
|
1421
|
+
ctx.phase = (phase, info = {}) => {
|
|
1422
|
+
if (progressToken === undefined || sse.closed || controller.signal.aborted) return
|
|
1423
|
+
progress += 1
|
|
1424
|
+
const side = info.title && /对账 · .* · (左侧|右侧)$/.exec(info.title)?.[1]
|
|
1425
|
+
const label = `${steps[phase] ?? phase}${side ? ` (${side === '左侧' ? 'left' : 'right'} side)` : ''}${info.datasource ? ` · ${info.datasource}` : ''}${info.rows !== undefined ? ` · ${info.rows} rows` : ''}${phase === 'widget' ? ` ${info.index}/${info.total} · ${info.title}` : ''}`
|
|
1426
|
+
sse.send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken, progress, message: label } })
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
let r
|
|
1430
|
+
try {
|
|
1431
|
+
r = await this.mcpHandleMessage(msg, ctx)
|
|
1432
|
+
} finally {
|
|
1433
|
+
if (inflight) this.mcpInflight.delete(inflight)
|
|
1434
|
+
}
|
|
1435
|
+
// Call log (requests only; not notifications).
|
|
1436
|
+
if (isRequest && !ctx.tok.temporary) {
|
|
1437
|
+
const status = ctx.cancelled || controller.signal.aborted ? 'cancelled' : r.body?.error ? 'error' : r.body?.result?.isError ? 'error' : 'ok'
|
|
1438
|
+
this.mcpLogCall(ctx.tok, { method: msg.method, tool: ctx.toolName, datasource: ctx.callInfo?.datasource, ms: Date.now() - started, status, rows: ctx.callInfo?.rows, error: status === 'error' ? (r.body?.error?.message ?? ctx.callError) : status === 'cancelled' ? String(controller.signal.reason ?? 'cancelled') : undefined, sql: msg.method === 'tools/call' ? (typeof params.arguments?.sql === 'string' ? params.arguments.sql : ctx.callInfo?.sql) : undefined, version: ctx.version })
|
|
1439
|
+
}
|
|
1440
|
+
// Cancelled: send nothing more (cancellation spec: "Not send a response for the cancelled request").
|
|
1441
|
+
if (ctx.cancelled || controller.signal.aborted) {
|
|
1442
|
+
if (sse) sse.close()
|
|
1443
|
+
else if (!res.headersSent && !res.destroyed) { res.writeHead(204); res.end() }
|
|
1444
|
+
return
|
|
1445
|
+
}
|
|
1446
|
+
if (sse) {
|
|
1447
|
+
if (r.body && !r.cancelled) { ctx.phase?.('formatting'); sse.send(r.body) }
|
|
1448
|
+
sse.close()
|
|
1449
|
+
return
|
|
1450
|
+
}
|
|
622
1451
|
return send(r.status, r.body, r.headers)
|
|
623
1452
|
},
|
|
624
1453
|
|
|
1454
|
+
/**
|
|
1455
|
+
* 2026-07-28 subscriptions/listen: long-lived SSE stream for opted-in change notifications.
|
|
1456
|
+
* https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions
|
|
1457
|
+
*/
|
|
1458
|
+
async mcpListen(msg, ctx, req, res, send) {
|
|
1459
|
+
const params = msg.params ?? {}
|
|
1460
|
+
// Reuse the version / header validation of mcpHandleMessage (Mcp-Method is checked against the real method here).
|
|
1461
|
+
if (ctx.headers['mcp-method'] && ctx.headers['mcp-method'] !== 'subscriptions/listen') return send(400, { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32020, message: `Header mismatch: Mcp-Method '${ctx.headers['mcp-method']}' does not match body 'subscriptions/listen'` } })
|
|
1462
|
+
const { 'mcp-method': _m, ...headers } = ctx.headers
|
|
1463
|
+
const pre = await this.mcpHandleMessage({ jsonrpc: '2.0', id: msg.id, method: 'ping', params: { _meta: params._meta } }, { ...ctx, headers })
|
|
1464
|
+
if (pre.body?.error) return send(pre.status, pre.body)
|
|
1465
|
+
if (!params._meta?.['io.modelcontextprotocol/protocolVersion']) return send(200, { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32601, message: 'Method not found: subscriptions/listen (2026-07-28 only; legacy clients use GET + resources/subscribe)' } })
|
|
1466
|
+
if (!acceptsSse(req.headers.accept)) return send(406, { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32600, message: 'subscriptions/listen needs Accept: text/event-stream' } })
|
|
1467
|
+
const f = params.notifications ?? {}
|
|
1468
|
+
// Only the types we support are acknowledged (tools/prompts lists are static here).
|
|
1469
|
+
const filter = {
|
|
1470
|
+
...(f.resourcesListChanged ? { resourcesListChanged: true } : {}),
|
|
1471
|
+
...(Array.isArray(f.resourceSubscriptions) ? { resourceSubscriptions: f.resourceSubscriptions.map(String).filter(u => u.startsWith('nl2sql://') && this.mcpCanSee(ctx.actor, u)).slice(0, 200) } : {}),
|
|
1472
|
+
}
|
|
1473
|
+
this.mcpListeners ??= new Set()
|
|
1474
|
+
const listener = { id: msg.id, tokenId: ctx.tok.id, filter }
|
|
1475
|
+
const sse = this.mcpOpenSse(res, {}, () => { this.mcpListeners.delete(listener); this.mcpEnsureWatcher() })
|
|
1476
|
+
listener.send = (n) => sse.send(n)
|
|
1477
|
+
// Server-side teardown: graceful completion result, then close (subscriptions "Graceful Closure").
|
|
1478
|
+
listener.close = () => { sse.send({ jsonrpc: '2.0', id: msg.id, result: { resultType: 'complete', _meta: { 'io.modelcontextprotocol/subscriptionId': msg.id } } }); sse.close() }
|
|
1479
|
+
this.mcpListeners.add(listener)
|
|
1480
|
+
sse.send({ jsonrpc: '2.0', method: 'notifications/subscriptions/acknowledged', params: { _meta: { 'io.modelcontextprotocol/subscriptionId': msg.id }, notifications: filter } })
|
|
1481
|
+
this.mcpEnsureWatcher()
|
|
1482
|
+
},
|
|
1483
|
+
|
|
625
1484
|
/** (Re)start or stop the server according to state.mcp. */
|
|
626
1485
|
async applyMcp() {
|
|
627
1486
|
await this.stopMcp()
|
|
@@ -633,7 +1492,7 @@ export const methods = {
|
|
|
633
1492
|
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
1493
|
})
|
|
635
1494
|
})
|
|
636
|
-
server.requestTimeout =
|
|
1495
|
+
server.requestTimeout = 0 // long-lived SSE streams (GET / subscriptions/listen); tools/call is bounded by query timeouts
|
|
637
1496
|
server.headersTimeout = 30000
|
|
638
1497
|
try {
|
|
639
1498
|
await new Promise((resolve, reject) => {
|
|
@@ -652,7 +1511,10 @@ export const methods = {
|
|
|
652
1511
|
async stopMcp() {
|
|
653
1512
|
const server = this.mcpServer
|
|
654
1513
|
this.mcpServer = undefined
|
|
655
|
-
this.
|
|
1514
|
+
for (const l of [...(this.mcpListeners ?? [])]) l.close()
|
|
1515
|
+
for (const sid of [...(this.mcpSessions?.keys() ?? [])]) this.mcpEndSession(sid)
|
|
1516
|
+
for (const c of this.mcpInflight?.values() ?? []) c.abort('server stopped')
|
|
1517
|
+
if (this.mcpWatchTimer) { clearInterval(this.mcpWatchTimer); this.mcpWatchTimer = undefined }
|
|
656
1518
|
if (!server) return
|
|
657
1519
|
await new Promise(resolve => {
|
|
658
1520
|
server.close(() => resolve())
|
|
@@ -668,7 +1530,11 @@ export const FEATURE = {
|
|
|
668
1530
|
mcpStatus: () => service.mcpStatus(),
|
|
669
1531
|
saveMcpSettings: (p) => service.saveMcpSettings(p ?? {}),
|
|
670
1532
|
createMcpToken: (p) => service.createMcpToken(p ?? {}),
|
|
1533
|
+
updateMcpToken: (p) => service.updateMcpToken(p ?? {}),
|
|
1534
|
+
rotateMcpToken: (p) => service.rotateMcpToken(p ?? {}),
|
|
671
1535
|
revokeMcpToken: (p) => service.revokeMcpToken(p ?? {}),
|
|
1536
|
+
mcpCallLog: (p) => service.mcpCallLog(p ?? {}),
|
|
1537
|
+
mcpSelfTest: () => service.mcpSelfTest(),
|
|
672
1538
|
}),
|
|
673
1539
|
start(ctx, service) {
|
|
674
1540
|
ctx.effect(() => () => service.stopMcp(), 'nl2sql: mcp server')
|