@hedeqiang/dsh-nl2sql 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/service/mcp.js CHANGED
@@ -1,27 +1,45 @@
1
1
  /**
2
- * MCP server: other AI tools (Claude Desktop, Cursor, Cline, VS Code) reach the
3
- * plugin's governed data access through the Model Context Protocol.
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
- * Transport: Streamable HTTP on 127.0.0.1 only (POST /mcp, plain JSON responses;
6
- * GET / DELETE → 405), disabled by default. Dual-era: legacy clients use the
7
- * `initialize` handshake (2024-11-05 … 2025-11-25, Mcp-Session-Id minted on
8
- * initialize); modern clients (2026-07-28) send per-request `_meta` and may call
9
- * `server/discover`. No dependencies beyond node:http / node:crypto.
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
- * Security: bearer tokens created in the page (only SHA-256 hashes are stored);
12
- * every token maps to an actor — the owner, an existing member (scope, role,
13
- * row rules), or a role + data source list — and every query goes through
14
- * runQuery (read-only admission, access policy, row-level rules, masking,
15
- * audit with source 'mcp'). Foreign Origin → 403, non-local Host → 403
16
- * (DNS-rebinding defence), missing / invalid token → 401.
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
- * State: state.mcp = { enabled, port, tokens: [{ id, name, hash, prefix, actor, createdAt, lastUsedAt }] }.
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 { UserError, clampInt, newId, oneOf, requireText, summarize } from './shared.js'
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
- /** Tool definitions for external models. `extra(service)` decides runtime-optional tools. */
70
- function toolDefs(service) {
71
- const ro = { readOnlyHint: true, openWorldHint: false }
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
- annotations: ro,
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 (see get_schema / search_knowledge) — do not re-derive them in SQL. Optional time grain, dimensions (allowed ones only), time range and filters.',
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
- annotations: ro,
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
- annotations: ro,
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
- annotations: ro,
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
- annotations: ro,
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
- const PROMPTS = [
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 { id: tok.id, name: tok.name, prefix: tok.prefix, scope, createdAt: tok.createdAt, lastUsedAt: tok.lastUsedAt ?? null }
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
- for (const [sid, sess] of this.mcpSessions ?? []) if (sess.tokenId === id) this.mcpSessions.delete(sid)
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 JSON.parse(JSON.stringify(view, (_k, v) => (typeof v === 'bigint' ? v.toString() : typeof v === 'number' && !Number.isFinite(v) ? null : v)))
652
+ return jsonSafe(view)
388
653
  },
389
654
 
390
- /** Run one MCP tool as `actor`. Returns a plain object (structuredContent). */
391
- async mcpCallTool(name, args, actor) {
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.schemaForModel(schema, ds, tables, { question: typeof a.question === 'string' ? a.question : undefined }).replace(/nl2sql_schema/g, 'get_schema').replace(/nl2sql_knowledge action=search/g, 'search_knowledge') }
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 JSON.parse(JSON.stringify({
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) : JSON.parse(JSON.stringify(out ?? null))
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
- /** Handle one JSON-RPC message. Returns { status, body?, headers? }. */
458
- async mcpHandleMessage(msg, { tok, actor, headers, session }) {
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) return { status: 202 } // notifications/initialized, cancelled, …
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 version = MCP_VERSIONS.includes(requested) && !MODERN.has(requested) ? requested : LATEST_LEGACY
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.mcpSessions.delete(this.mcpSessions.keys().next().value)
498
- return reply({ protocolVersion: version, capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, prompts: { listChanged: false } }, serverInfo: SERVER_INFO, instructions: INSTRUCTIONS }, { headers: { 'mcp-session-id': sid } })
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
- return reply({ tools: toolDefs(this) })
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
- return reply({ content: [{ type: 'text', text }], structuredContent: value && typeof value === 'object' && !Array.isArray(value) ? value : { value }, isError: false })
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
- return reply({
521
- resources: this.mcpVisibleDatasources(actor).map(d => ({
522
- uri: `nl2sql://datasource/${encodeURIComponent(d.id)}/schema`, name: `${d.name} schema`, title: `${d.name} — schema overview`,
523
- description: `Visible tables, columns, relations and dialect notes of ${d.name} (${DIALECTS[d.type ?? 'mysql']?.label ?? d.type}).`, mimeType: 'text/plain',
524
- })),
525
- })
526
- case 'resources/templates/list':
527
- return reply({ resourceTemplates: [{ uriTemplate: 'nl2sql://datasource/{id}/schema', name: 'Data source schema', description: 'Schema overview of a data source', mimeType: 'text/plain' }, { uriTemplate: 'nl2sql://datasource/{id}/table/{table}', name: 'Table schema', description: 'Full description of one table', mimeType: 'text/plain' }] })
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 m = /^nl2sql:\/\/datasource\/([^/]+)\/(schema|table\/(.+))$/.exec(uri)
531
- if (!m) return fail(-32002, 'Resource not found', { uri })
532
- let ds
533
- try { ds = this.mcpDatasource(actor, decodeURIComponent(m[1])) } catch { return fail(-32002, 'Resource not found', { uri }) }
534
- const schema = await this.visibleSchema(ds)
535
- const table = m[3] ? decodeURIComponent(m[3]) : undefined
536
- if (table && !schema.tables.some(t => t.name.toLowerCase() === table.toLowerCase())) return fail(-32002, 'Resource not found', { uri })
537
- const text = this.schemaForModel(schema, ds, table ? [table] : undefined).replace(/nl2sql_schema/g, 'get_schema').replace(/nl2sql_knowledge action=search/g, 'search_knowledge')
538
- return reply({ contents: [{ uri, mimeType: 'text/plain', text }] })
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 'logging/setLevel':
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 (req.method !== 'POST') return send(405, rpcErr(-32600, 'Method Not Allowed: use POST'), { allow: 'POST' })
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'), { 'www-authenticate': 'Bearer realm="nl2sql-mcp"' })
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
- const sid = req.headers['mcp-session-id']
1366
+ // 2026-07-28 requests ignore the header (streamable-http "Backward Compatibility").
600
1367
  let session
601
- if (sid && !(Array.isArray(msg) ? msg : [msg]).some(m => m?.method === 'initialize')) {
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
- const r = await this.mcpHandleMessage(msg, ctx)
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 = 15 * 60 * 1000
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.mcpSessions?.clear()
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')