@hedeqiang/dsh-nl2sql 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.0] - 2026-11-13
9
+
10
+ ### Added
11
+ - **MCP streaming and progress:** long tool calls stream over SSE with `notifications/progress` (admitted → running → rows fetched → formatting); the stdio bridge forwards them.
12
+ - **MCP cancellation:** `notifications/cancelled` (or closing the stream on 2026-07-28) stops the request; MySQL queries are killed server-side, other engines drop the result; the audit records `cancelled`.
13
+ - **MCP resources:** knowledge, metrics, data freshness, dashboards and query results (privacy-aware CSV); subscriptions with `resources/updated` and `list_changed` notifications; cursor pagination.
14
+ - **MCP tools:** annotations, `outputSchema` with structured content and `resource_link` items; new `list_metrics`, `list_dashboards`, `run_dashboard`, `data_freshness`, `explain_sql`.
15
+ - **MCP prompts and completion:** `analyze_table`, `compare_periods`, `reconcile_sources`; argument completion for data sources, tables, metrics and periods, scoped to the token; log notifications for truncation, stale data and redaction.
16
+ - **Tokens:** expiry, tool allowlists, rate limits (429 with Retry-After), secret rotation and a per-token call log.
17
+ - **Admin page:** setup guides for Claude Desktop, Claude Code, Cursor, VS Code, Cline, Windsurf, Codex CLI, Gemini CLI, Zed and Continue; a connection self-test; token editor; call log.
18
+ - Query history shows cancelled queries.
19
+
8
20
  ## [0.13.1] - 2026-11-12
9
21
 
10
22
  ### Fixed
@@ -127,7 +139,8 @@ These features are tested with local databases and fake IM / Lark servers; real
127
139
 
128
140
  - Redesigned UI; Lark push (Markdown and native charts); access control, feedback loop, dashboard global filters; PostgreSQL / Elasticsearch, SSH tunnels, privacy settings, automatic overviews, scheduled reports; semantic layer, ER graph, scan guard, history and audit, dashboards; first version.
129
141
 
130
- [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.1...HEAD
142
+ [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.0...HEAD
143
+ [0.14.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.1...v0.14.0
131
144
  [0.13.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.0...v0.13.1
132
145
  [0.13.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.2...v0.13.0
133
146
  [0.12.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.1...v0.12.2
@@ -10,6 +10,17 @@
10
10
  * NL2SQL_MCP_TOKEN bearer token created in Data Q&A → MCP access (required)
11
11
  *
12
12
  * npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp
13
+ *
14
+ * Streaming (https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http):
15
+ * SSE responses are parsed incrementally, so notifications/progress and notifications/message
16
+ * reach stdout while the request runs; `:` comment lines (heartbeats) are ignored. A
17
+ * subscriptions/listen request stays open and forwards its notifications.
18
+ * Cancellation (https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation):
19
+ * on stdio the client sends notifications/cancelled; the bridge closes the HTTP response
20
+ * stream of that request (the HTTP cancellation signal) and also forwards the notification
21
+ * for legacy sessions. Legacy sessions (2025-03-26 … 2025-11-25): after initialize the bridge
22
+ * opens the standalone GET SSE stream and forwards server notifications (resources/updated,
23
+ * resources/list_changed).
13
24
  */
14
25
  const URL_ = process.env.NL2SQL_MCP_URL || 'http://127.0.0.1:47860/mcp'
15
26
  const TOKEN = process.env.NL2SQL_MCP_TOKEN || ''
@@ -26,6 +37,9 @@ let sessionId
26
37
  let protocolVersion
27
38
  let pending = 0
28
39
  let ended = false
40
+ let getStream // AbortController of the legacy GET stream
41
+ /** In-flight requests: JSON-RPC id → AbortController (closing the response stream cancels on the server). */
42
+ const inflight = new Map()
29
43
 
30
44
  /** Header-safe value (Base64 sentinel for non-ASCII), per the Streamable HTTP binding. */
31
45
  function headerValue(v) {
@@ -33,56 +47,119 @@ function headerValue(v) {
33
47
  return /^[\x20-\x7e]*$/.test(s) && s.trim() === s && !/^=\?base64\?.*\?=$/.test(s) ? s : `=?base64?${Buffer.from(s, 'utf8').toString('base64')}?=`
34
48
  }
35
49
 
36
- async function forward(msg) {
37
- const isRequest = msg && typeof msg === 'object' && 'id' in msg && typeof msg.method === 'string'
50
+ /** Read an SSE body incrementally; calls onMessage for every `data:` event (comments ignored). */
51
+ async function readSse(body, onMessage) {
52
+ const decoder = new TextDecoder()
53
+ let buf = ''
54
+ const flush = (block) => {
55
+ const data = block.split(/\r?\n/).filter(l => l.startsWith('data:')).map(l => l.slice(5).replace(/^ /, '')).join('\n')
56
+ if (data) { try { onMessage(JSON.parse(data)) } catch {} }
57
+ }
58
+ for await (const chunk of body) {
59
+ buf += decoder.decode(chunk, { stream: true })
60
+ let m
61
+ while ((m = /\r?\n\r?\n/.exec(buf))) {
62
+ flush(buf.slice(0, m.index))
63
+ buf = buf.slice(m.index + m[0].length)
64
+ }
65
+ }
66
+ if (buf.trim()) flush(buf)
67
+ }
68
+
69
+ function headersFor(msg) {
38
70
  const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
39
71
  if (TOKEN) headers.authorization = `Bearer ${TOKEN}`
40
- if (sessionId) headers['mcp-session-id'] = sessionId
41
72
  const metaVersion = msg?.params?._meta?.['io.modelcontextprotocol/protocolVersion']
73
+ if (sessionId && !metaVersion) headers['mcp-session-id'] = sessionId
42
74
  const version = metaVersion || protocolVersion
43
75
  if (version && msg?.method !== 'initialize') headers['mcp-protocol-version'] = version
44
76
  if (typeof msg?.method === 'string') headers['mcp-method'] = msg.method
45
77
  const name = msg?.params?.name ?? msg?.params?.uri
46
78
  if (name !== undefined && metaVersion) headers['mcp-name'] = headerValue(name)
47
- let res
79
+ return headers
80
+ }
81
+
82
+ /** Legacy standalone GET SSE stream for server notifications (resources/updated, list_changed). */
83
+ async function openGetStream() {
84
+ if (!sessionId || getStream) return
85
+ const ctl = new AbortController()
86
+ getStream = ctl
48
87
  try {
49
- res = await fetch(URL_, { method: 'POST', headers, body: JSON.stringify(msg), signal: AbortSignal.timeout(TIMEOUT_MS) })
50
- } catch (e) {
51
- if (isRequest) errorReply(msg.id, -32000, `Cannot reach the nl2sql MCP server at ${URL_} (${e.cause?.code ?? e.message}). Enable it in DeepSeek Harness → Data Q&A → MCP access and keep DeepSeek Harness running.`)
52
- return
53
- }
54
- const sid = res.headers.get('mcp-session-id')
55
- if (sid) sessionId = sid
56
- const type = res.headers.get('content-type') ?? ''
57
- const text = await res.text()
58
- if (res.status === 202 || !text) {
59
- if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status} without a response body`)
60
- return
61
- }
62
- const emit = (body) => {
63
- if (body?.result?.protocolVersion && msg?.method === 'initialize') protocolVersion = body.result.protocolVersion
64
- write(body)
88
+ const headers = { accept: 'text/event-stream', 'mcp-session-id': sessionId }
89
+ if (TOKEN) headers.authorization = `Bearer ${TOKEN}`
90
+ if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion
91
+ const res = await fetch(URL_, { method: 'GET', headers, signal: ctl.signal })
92
+ if (res.status !== 200 || !(res.headers.get('content-type') ?? '').includes('text/event-stream')) { await res.body?.cancel?.(); return }
93
+ await readSse(res.body, (m) => { if (m && !('id' in m)) write(m) })
94
+ } catch {} finally { if (getStream === ctl) getStream = undefined }
95
+ }
96
+
97
+ async function forward(msg) {
98
+ const isRequest = msg && typeof msg === 'object' && 'id' in msg && typeof msg.method === 'string'
99
+ // stdio cancellation → close the HTTP stream of that request.
100
+ if (msg?.method === 'notifications/cancelled' && msg.params?.requestId !== undefined) {
101
+ const ctl = inflight.get(msg.params.requestId)
102
+ if (ctl) ctl.abort()
103
+ // Legacy sessions also get the notification (the server stops the work by request id).
104
+ if (!sessionId) return
65
105
  }
66
- if (type.includes('text/event-stream')) {
67
- // Request-scoped SSE stream: forward every `data:` event.
68
- for (const block of text.split(/\r?\n\r?\n/)) {
69
- const data = block.split(/\r?\n/).filter(l => l.startsWith('data:')).map(l => l.slice(5).replace(/^ /, '')).join('\n')
70
- if (data) { try { emit(JSON.parse(data)) } catch {} }
106
+ const ctl = new AbortController()
107
+ const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS)
108
+ if (isRequest) inflight.set(msg.id, ctl)
109
+ let res
110
+ try {
111
+ try {
112
+ res = await fetch(URL_, { method: 'POST', headers: headersFor(msg), body: JSON.stringify(msg), signal: ctl.signal })
113
+ } catch (e) {
114
+ if (isRequest && !ctl.signal.aborted) errorReply(msg.id, -32000, `Cannot reach the nl2sql MCP server at ${URL_} (${e.cause?.code ?? e.message}). Enable it in DeepSeek Harness → Data Q&A → MCP access and keep DeepSeek Harness running.`)
115
+ return
71
116
  }
72
- return
73
- }
74
- let body
75
- try { body = JSON.parse(text) } catch {
76
- if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status}: ${text.slice(0, 200)}`)
77
- return
117
+ const sid = res.headers.get('mcp-session-id')
118
+ if (sid) sessionId = sid
119
+ const type = res.headers.get('content-type') ?? ''
120
+ const emit = (body) => {
121
+ if (body?.result?.protocolVersion && msg?.method === 'initialize') protocolVersion = body.result.protocolVersion
122
+ write(body)
123
+ }
124
+ if (type.includes('text/event-stream')) {
125
+ // Request-scoped SSE stream: forward every event as it arrives (progress, log messages, then the response).
126
+ try {
127
+ await readSse(res.body, emit)
128
+ } catch (e) {
129
+ if (!ctl.signal.aborted && isRequest) errorReply(msg.id, -32000, `Stream interrupted: ${e.message}`)
130
+ }
131
+ return
132
+ }
133
+ const text = await res.text()
134
+ if (res.status === 202 || res.status === 204 || !text) {
135
+ if (isRequest && res.status !== 204) errorReply(msg.id, -32000, `HTTP ${res.status} without a response body`)
136
+ return
137
+ }
138
+ let body
139
+ try { body = JSON.parse(text) } catch {
140
+ if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status}: ${text.slice(0, 200)}`)
141
+ return
142
+ }
143
+ if (Array.isArray(body)) { for (const b of body) emit(b); return }
144
+ if (body && typeof body === 'object' && body.jsonrpc === '2.0') {
145
+ // Errors without an id (401 / 403 / 429) belong to this request.
146
+ if (isRequest && (body.id === null || body.id === undefined) && body.error) body.id = msg.id
147
+ if (res.status === 404 && sessionId && msg?.method !== 'initialize') { sessionId = undefined; getStream?.abort() }
148
+ emit(body)
149
+ if (msg?.method === 'initialize' && sessionId && body.result) openGetStream()
150
+ } else if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status}`)
151
+ } catch (e) {
152
+ if (!ctl.signal.aborted) throw e
153
+ } finally {
154
+ clearTimeout(timer)
155
+ if (isRequest && inflight.get(msg.id) === ctl) inflight.delete(msg.id)
78
156
  }
79
- if (Array.isArray(body)) { for (const b of body) emit(b); return }
80
- if (body && typeof body === 'object' && body.jsonrpc === '2.0') {
81
- // Errors without an id (401 / 403) belong to this request.
82
- if (isRequest && (body.id === null || body.id === undefined) && body.error) body.id = msg.id
83
- if (res.status === 404 && sessionId && msg?.method !== 'initialize') sessionId = undefined
84
- emit(body)
85
- } else if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status}`)
157
+ }
158
+
159
+ function finish() {
160
+ if (!ended || pending) return
161
+ getStream?.abort()
162
+ process.exit(0)
86
163
  }
87
164
 
88
165
  let buffer = ''
@@ -97,7 +174,7 @@ process.stdin.on('data', (chunk) => {
97
174
  let msg
98
175
  try { msg = JSON.parse(line) } catch { errorReply(null, -32700, 'Parse error'); continue }
99
176
  pending++
100
- forward(msg).catch(e => log('forward failed:', e.message)).finally(() => { pending--; if (ended && !pending) process.exit(0) })
177
+ forward(msg).catch(e => log('forward failed:', e.message)).finally(() => { pending--; finish() })
101
178
  }
102
179
  })
103
- process.stdin.on('end', () => { ended = true; if (!pending) process.exit(0) })
180
+ process.stdin.on('end', () => { ended = true; finish() })
package/docs/mcp.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # MCP server
2
2
 
3
- dsh-nl2sql can expose its governed data access to other AI tools — Claude Desktop, Cursor, Cline, VS Code (GitHub Copilot agent mode) and any other [Model Context Protocol](https://modelcontextprotocol.io/specification/) client. External models get the same guard rails as the built-in Data Q&A agent:
3
+ dsh-nl2sql can expose its governed data access to other AI tools — Claude Desktop, Claude Code, Cursor, VS Code (GitHub Copilot), Cline, Windsurf, OpenAI Codex, Gemini CLI, Zed, Continue and any other [Model Context Protocol](https://modelcontextprotocol.io/specification/) client. External models get the same guard rails as the built-in Data Q&A agent:
4
4
 
5
5
  - every statement passes **read-only admission** (one SELECT / WITH / SHOW / EXPLAIN; writes, DDL, multiple statements and system catalogs are refused);
6
6
  - **access policies** (hidden tables and columns, masked columns) and **sensitive-column masking** apply;
7
7
  - each token runs as an **actor** — the local owner, an existing **member** (role, data source scope, row-level rules) or a **role + data sources** (+ optional row rule);
8
- - the **privacy setting** "result data sent to the model" decides whether the model sees rows, per-column statistics, or only row counts;
9
- - every call is **audited** in History with source `mcp` and the token name.
8
+ - the **privacy setting** "result data sent to the model" decides whether the model sees rows, per-column statistics, or only row counts — for tool results, `nl2sql://result/…` resources and dashboard runs alike;
9
+ - every call is **audited** in History with source `mcp` and the token name, and logged per token in the **call log**.
10
10
 
11
11
  The server runs inside DeepSeek Harness, so Harness must be running while you use it.
12
12
 
@@ -14,17 +14,24 @@ The server runs inside DeepSeek Harness, so Harness must be running while you us
14
14
 
15
15
  1. Open **Data Q&A → 管理 / Admin → MCP 接入 / MCP access**.
16
16
  2. Turn the server on. It listens on `http://127.0.0.1:47860/mcp` by default (change the port there; the server restarts automatically). It is **off by default** and never binds to other interfaces.
17
- 3. Click **New token**, give it a name and choose its scope:
17
+ 3. Click **测试连接 / Test connection**: the page runs `server/discover`, `tools/list` and a legacy `initialize` against the running server over loopback with a temporary in-memory credential (nothing is stored) and shows each step.
18
+ 4. Click **New token**, give it a name and choose its scope:
18
19
  - **Local owner** — the same access as asking on the page (policies still apply);
19
20
  - **A member** — follows that member's role, data sources and row-level rules, live; disabling or deleting the member makes the token stop working;
20
21
  - **Role + data sources** — analyst / viewer / admin on the selected data sources, with an optional row rule (`column IN (values)`).
21
- 4. Copy the token — it is shown **once**. Only its SHA-256 hash is stored; if you lose it, revoke it and create a new one.
22
22
 
23
- The page shows ready-to-copy snippets for the clients below, filled in with the new token.
23
+ Optional limits: **expiry date**, **allowed tools** (checkbox list) and a **rate limit** (requests per minute). They can be changed later with **Edit**; **Rotate secret** issues a new secret (shown once) and invalidates the old one at once.
24
+ 5. Copy the token — it is shown **once**. Only its SHA-256 hash is stored; if you lose it, rotate it.
24
25
 
25
- ## Claude Desktop (stdio bridge)
26
+ The page has a setup wizard with a tab per client below, filled in with the real URL and — while it is shown — the new token.
26
27
 
27
- Claude Desktop launches local MCP servers as processes speaking stdio. The package ships a small bridge, `nl2sql-mcp` (plain Node ≥ 18, no dependencies), that relays stdio to the HTTP endpoint. Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
28
+ ## Client setup
29
+
30
+ Formats were checked against each client's official documentation (November 2026). Replace `<YOUR_TOKEN>` with your token.
31
+
32
+ ### Claude Desktop (stdio bridge)
33
+
34
+ [Connect to local MCP servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers): Settings → Developer → Edit Config opens `claude_desktop_config.json` (macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%\Claude\`). Claude Desktop launches local servers as processes speaking stdio; the package ships a bridge, `nl2sql-mcp` (plain Node ≥ 18, no dependencies), that relays stdio to the HTTP endpoint:
28
35
 
29
36
  ```json
30
37
  {
@@ -41,7 +48,7 @@ Claude Desktop launches local MCP servers as processes speaking stdio. The packa
41
48
  }
42
49
  ```
43
50
 
44
- You can run the bridge by hand to check it: `NL2SQL_MCP_TOKEN=… npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp`, then type `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`.
51
+ The bridge parses SSE responses incrementally and forwards `notifications/progress` / `notifications/message` to stdout before the response; on `notifications/cancelled` it closes the HTTP stream of that request (the HTTP cancellation signal). For legacy sessions it opens the standalone GET stream after `initialize` and forwards `notifications/resources/updated` / `list_changed`.
45
52
 
46
53
  | Variable | Default | Meaning |
47
54
  |---|---|---|
@@ -49,42 +56,98 @@ You can run the bridge by hand to check it: `NL2SQL_MCP_TOKEN=… npx -y -p @hed
49
56
  | `NL2SQL_MCP_TOKEN` | — | Bearer token (required) |
50
57
  | `NL2SQL_MCP_TIMEOUT_MS` | 660000 | Per-request timeout |
51
58
 
52
- ## Cursor
59
+ Check it by hand: `NL2SQL_MCP_TOKEN=… npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp`, then type `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`.
60
+
61
+ ### Claude Code
62
+
63
+ [Claude Code MCP docs](https://code.claude.com/docs/en/mcp):
64
+
65
+ ```bash
66
+ claude mcp add --transport http nl2sql http://127.0.0.1:47860/mcp \
67
+ --header "Authorization: Bearer <YOUR_TOKEN>"
53
68
 
54
- Cursor speaks Streamable HTTP directly. Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
69
+ # stdio variant (bridge)
70
+ claude mcp add --transport stdio --env NL2SQL_MCP_URL=http://127.0.0.1:47860/mcp --env NL2SQL_MCP_TOKEN=<YOUR_TOKEN> nl2sql -- npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp
71
+ ```
72
+
73
+ ### Cursor
74
+
75
+ [Cursor MCP docs](https://cursor.com/docs/mcp): `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
55
76
 
56
77
  ```json
57
- {
58
- "mcpServers": {
59
- "nl2sql": {
60
- "url": "http://127.0.0.1:47860/mcp",
61
- "headers": { "Authorization": "Bearer <YOUR_TOKEN>" }
62
- }
63
- }
64
- }
78
+ { "mcpServers": { "nl2sql": { "url": "http://127.0.0.1:47860/mcp", "headers": { "Authorization": "Bearer <YOUR_TOKEN>" } } } }
65
79
  ```
66
80
 
67
- ## VS Code
81
+ ### VS Code
68
82
 
69
- Add to `.vscode/mcp.json` (or the user-level MCP configuration):
83
+ [VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) (`servers` format in `.vscode/mcp.json` or the user profile `mcp.json`):
70
84
 
71
85
  ```json
72
- {
73
- "servers": {
74
- "nl2sql": {
75
- "type": "http",
76
- "url": "http://127.0.0.1:47860/mcp",
77
- "headers": { "Authorization": "Bearer <YOUR_TOKEN>" }
78
- }
79
- }
80
- }
86
+ { "servers": { "nl2sql": { "type": "http", "url": "http://127.0.0.1:47860/mcp", "headers": { "Authorization": "Bearer <YOUR_TOKEN>" } } } }
81
87
  ```
82
88
 
83
- Cline and other clients: use the HTTP URL with an `Authorization: Bearer …` header when the client supports remote/HTTP servers, otherwise the stdio bridge command above.
89
+ ### Cline
90
+
91
+ [Configuring MCP servers](https://docs.cline.bot/mcp/configuring-mcp-servers): `cline_mcp_settings.json` (Cline panel → MCP Servers → Configure). Set `"type": "streamableHttp"` explicitly — omitting it means legacy SSE:
92
+
93
+ ```json
94
+ { "mcpServers": { "nl2sql": { "type": "streamableHttp", "url": "http://127.0.0.1:47860/mcp", "headers": { "Authorization": "Bearer <YOUR_TOKEN>" }, "disabled": false } } }
95
+ ```
96
+
97
+ ### Windsurf
98
+
99
+ [Windsurf Cascade MCP](https://docs.devin.ai/windsurf/plugins/cascade/mcp) (formerly docs.windsurf.com): `mcp_config.json`, remote servers use `serverUrl` (or `url`) plus `headers`:
100
+
101
+ ```json
102
+ { "mcpServers": { "nl2sql": { "serverUrl": "http://127.0.0.1:47860/mcp", "headers": { "Authorization": "Bearer <YOUR_TOKEN>" } } } }
103
+ ```
104
+
105
+ ### OpenAI Codex CLI
106
+
107
+ [Codex MCP docs](https://developers.openai.com/codex/mcp): `~/.codex/config.toml` (or `.codex/config.toml` in a trusted project). `bearer_token_env_var = "NL2SQL_MCP_TOKEN"` is an alternative that keeps the token out of the file:
108
+
109
+ ```toml
110
+ [mcp_servers.nl2sql]
111
+ url = "http://127.0.0.1:47860/mcp"
112
+ http_headers = { "Authorization" = "Bearer <YOUR_TOKEN>" }
113
+ ```
114
+
115
+ ### Gemini CLI
116
+
117
+ [Gemini CLI MCP servers](https://geminicli.com/docs/tools/mcp-server/): `~/.gemini/settings.json` or `.gemini/settings.json`; `httpUrl` is the Streamable HTTP endpoint (`url` would mean SSE):
118
+
119
+ ```json
120
+ { "mcpServers": { "nl2sql": { "httpUrl": "http://127.0.0.1:47860/mcp", "headers": { "Authorization": "Bearer <YOUR_TOKEN>" } } } }
121
+ ```
122
+
123
+ ### Zed
124
+
125
+ [Zed MCP docs](https://zed.dev/docs/ai/mcp): `settings.json`:
126
+
127
+ ```json
128
+ { "context_servers": { "nl2sql": { "url": "http://127.0.0.1:47860/mcp", "headers": { "Authorization": "Bearer <YOUR_TOKEN>" } } } }
129
+ ```
130
+
131
+ ### Continue
132
+
133
+ [Continue MCP deep dive](https://docs.continue.dev/customize/deep-dives/mcp) and [config.yaml reference](https://docs.continue.dev/reference) (`requestOptions` for `streamable-http` servers): save as `.continue/mcpServers/nl2sql.yaml`:
134
+
135
+ ```yaml
136
+ name: nl2sql
137
+ version: 0.0.1
138
+ schema: v1
139
+ mcpServers:
140
+ - name: nl2sql
141
+ type: streamable-http
142
+ url: http://127.0.0.1:47860/mcp
143
+ requestOptions:
144
+ headers:
145
+ Authorization: Bearer <YOUR_TOKEN>
146
+ ```
84
147
 
85
148
  ## What the server offers
86
149
 
87
- **Tools** (all read-only):
150
+ **Tools** (all read-only; annotations `readOnlyHint: true`, `destructiveHint: false`, `openWorldHint: false`, `idempotentHint` true except where a call creates a new result id; each has an `outputSchema` and returns `structuredContent` plus the JSON as text; results that produce a `resultId` add a `resource_link` to `nl2sql://result/{resultId}`):
88
151
 
89
152
  | Tool | What it does |
90
153
  |---|---|
@@ -92,28 +155,76 @@ Cline and other clients: use the HTTP URL with an `Authorization: Bearer …` he
92
155
  | `get_schema` | Schema overview (visible tables, column roles, relations, dialect notes, glossary, metrics) or full detail of `tables` |
93
156
  | `query` | One read-only SQL statement → columns, rows / statistics / count per privacy setting, `rowCount`, `truncated`, `resultId` |
94
157
  | `query_metric` | A governed metric from the metric library (grain, dimensions, range, filters) |
158
+ | `list_metrics` | The metric library of a data source (expression, table, filter, time column, unit, dimensions, synonyms) |
95
159
  | `search_knowledge` | Verified question→SQL examples, glossary and metric definitions related to a question |
96
160
  | `drill` | Breakdown / detail of an earlier `resultId` (row rules re-applied) |
97
161
  | `explain_change` | Attribution: which dimensions explain the change between two periods of a result |
162
+ | `list_dashboards` | Dashboards with the widgets on data sources in scope, and their filters |
163
+ | `run_dashboard` | Run a dashboard's widgets (governed SQL, so viewer tokens may use it) with optional global filters; per-widget results per privacy (max 12 widgets per call) |
164
+ | `explain_sql` | Dry run: admission, policy and row-rule checks plus the EXPLAIN plan, full scans and whether the scan guard would refuse — no rows |
165
+ | `data_freshness` | Latest timestamp, lag and stale status per table — listed when the freshness feature is installed |
98
166
  | `reconcile` | Compare two queries (e.g. the same figures from two databases) — listed when the reconcile feature is installed |
99
167
  | `forecast` | Forecast a time-series result — listed when the forecast feature is installed |
100
168
 
101
169
  Tool errors (refused SQL, row-level restrictions, unknown tables) come back as results with `isError: true` so the model can correct itself.
102
170
 
103
- **Resources:** `nl2sql://datasource/{id}/schema` for every visible data source (and `nl2sql://datasource/{id}/table/{table}` for one table), as plain text.
171
+ **Resources** (`resources/list` is paginated; `resources/templates/list` lists the templates):
104
172
 
105
- **Prompts:** `ask_data` — a template that walks the model through list → schema → knowledge → query.
173
+ | URI | Content |
174
+ |---|---|
175
+ | `nl2sql://datasource/{id}/schema` | Schema overview (text) |
176
+ | `nl2sql://datasource/{id}/table/{table}` | One table in full (text) |
177
+ | `nl2sql://datasource/{id}/knowledge` | Glossary, metrics, verified examples (only those touching visible tables) and notes of visible tables / columns (JSON) |
178
+ | `nl2sql://datasource/{id}/metrics` | Metric definitions (JSON) |
179
+ | `nl2sql://datasource/{id}/freshness` | Freshness per table (JSON; when the freshness feature is installed) |
180
+ | `nl2sql://dashboards`, `nl2sql://dashboard/{id}` | Dashboards and widget definitions (titles, data source, chart type — not SQL, not data) |
181
+ | `nl2sql://result/{resultId}` | A query result as `text/csv`: with privacy `rows` the first `modelRows` rows (≤ 500, ≤ 256 KB); with `summary` / `none` only the column list and a `# privacy=…` note — never rows. Row-ruled tokens can only read results filtered by their own rules |
182
+
183
+ **Prompts:** `ask_data` (question), `analyze_table` (datasource, table), `compare_periods` (metric, period day/week/month), `reconcile_sources` (left, right descriptions).
184
+
185
+ **Completion** (`completion/complete`): data source names (`datasource`) and ids (`id` of the resource templates), table names of a data source (`table`, using `context.arguments.datasource` / `id`; policy-hidden tables are never suggested), metric names and synonyms (`metric`), and `period`. Only data sources in the token's scope are suggested.
186
+
187
+ ## Streaming, progress, cancellation, notifications
188
+
189
+ - **SSE responses.** A `tools/call` POST whose `Accept` lists `text/event-stream` and that carries `_meta.progressToken` (or asked for log messages) gets a request-scoped SSE stream (`X-Accel-Buffering: no`): `notifications/progress` with increasing `progress` and messages `admitted → running query → fetched rows (n) → formatting` (reconcile: per left / right side; run_dashboard: per widget), any `notifications/message`, then the final response, after which the stream closes. `:` comment heartbeats every 15 s. Everything else stays plain JSON.
190
+ - **Cancellation.** 2026-07-28: closing the response stream cancels the request. Legacy revisions: `notifications/cancelled` with the request id (same token and session). Either way the running MySQL statement is stopped (`KILL QUERY` on the server, the pooled connection is dropped), no response is sent, History records the query as `cancelled` and the call log as `cancelled`. Other engines: the result is dropped and audited `cancelled` once the driver returns (or times out).
191
+ - **Resource notifications.** A cheap watcher (every 5 s, only while someone listens) compares data source ids, dashboards, `schemaAnalyzedAt` and a knowledge fingerprint: adding / removing a data source or dashboard sends `notifications/resources/list_changed`; re-analysis sends `notifications/resources/updated` for the schema (and subscribed table) resources; knowledge edits for the knowledge / metrics resources.
192
+ - 2026-07-28: `subscriptions/listen` (POST, SSE) with `notifications: { resourcesListChanged, resourceSubscriptions: [uris] }`. The server acknowledges first (`notifications/subscriptions/acknowledged`, only the supported / visible subset), tags every notification with `io.modelcontextprotocol/subscriptionId` and, when it ends the subscription (shutdown, token revoked), sends the completion result before closing. `toolsListChanged` / `promptsListChanged` are not acknowledged (those lists are static).
193
+ - Legacy: `resources/subscribe` / `resources/unsubscribe` per `Mcp-Session-Id`; notifications arrive on the session's standalone `GET /mcp` SSE stream. `DELETE /mcp` ends the session.
194
+ - **Logging.** Warnings go out as `notifications/message` (logger `nl2sql`): truncated results (`warning`), stale data from `data_freshness` (`warning`), privacy redaction / row caps / masked columns (`notice`). Legacy: after `logging/setLevel` on the session. 2026-07-28: only for requests with `_meta["io.modelcontextprotocol/logLevel"]` (the feature is deprecated in that revision but still defined). Never without a level.
195
+ - **Pagination.** `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list` return opaque `nextCursor`s; invalid cursors → `-32602`.
196
+
197
+ ## Capability matrix per revision
198
+
199
+ | Feature | 2024-11-05 | 2025-03-26 | 2025-06-18 | 2025-11-25 | 2026-07-28 |
200
+ |---|---|---|---|---|---|
201
+ | Handshake | `initialize` + `Mcp-Session-Id` | same | same | same | none; `server/discover`, per-request `_meta` |
202
+ | Tool annotations | — | ✓ | ✓ | ✓ | ✓ |
203
+ | `title`, `outputSchema`, `structuredContent`, `resource_link` | — | — | ✓ | ✓ | ✓ |
204
+ | JSON-RPC batches | — | ✓ | — (accepted for compatibility) | — | — |
205
+ | Completion | — | ✓ | ✓ (+ `context.arguments`) | ✓ | ✓ |
206
+ | Progress + logs on request SSE | ✓ | ✓ | ✓ | ✓ | ✓ |
207
+ | Cancellation | `notifications/cancelled` | same | same | same | close the stream (stdio bridge: `notifications/cancelled`) |
208
+ | Resource updates | `resources/subscribe` + GET stream | same | same | same | `subscriptions/listen` |
209
+ | Log level | `logging/setLevel` | same | same | same | `_meta` `io.modelcontextprotocol/logLevel` (deprecated) |
210
+ | Resource not found | `-32002` | `-32002` | `-32002` | `-32002` | `-32602` |
211
+ | `ping` | ✓ | ✓ | ✓ | ✓ | removed (still answered) |
212
+
213
+ Clients that send no version information are answered with the latest legacy shapes (additive fields only). The 2024-11-05 HTTP+SSE transport (separate `endpoint` event) is not offered; such clients can use the stdio bridge.
106
214
 
107
215
  ## Protocol details
108
216
 
109
- - Streamable HTTP, `POST /mcp`, plain JSON responses (no SSE); `GET` / `DELETE` → `405`.
110
- - Dual-era: the 2026-07-28 revision (stateless, per-request `_meta`, `server/discover`, header checks `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name`) and the `initialize`-based revisions 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 (`initialize` negotiates the version and mints an `Mcp-Session-Id`; unknown sessions → `404`). `ping`, `notifications/*` (→ `202`) and JSON-RPC batches are accepted.
111
- - Unsupported versions get error `-32022` with the supported list.
217
+ - Streamable HTTP, `POST /mcp`; `GET` (SSE stream) and `DELETE` only for legacy sessions, otherwise `405`.
218
+ - Dual-era: 2026-07-28 (stateless, per-request `_meta`, `server/discover`, header checks `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` → `-32020`, `Mcp-Session-Id` ignored) and the `initialize`-based revisions 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 (`initialize` negotiates the version and mints an `Mcp-Session-Id`; unknown sessions → `404`). Unsupported versions get `-32022` with the supported list; invalid `logLevel` → `-32602`.
219
+
220
+ Specification pages used: [specification](https://modelcontextprotocol.io/specification/2026-07-28), [changelog 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/changelog), [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/changelog), [2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/changelog), [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/changelog); [Streamable HTTP (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http), [transports (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports); [subscriptions](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions), [progress](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress), [cancellation (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation), [cancellation (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation); [resources (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/server/resources), [resources (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/server/resources); [tools (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/server/tools), [tools (2025-03-26)](https://modelcontextprotocol.io/specification/2025-03-26/server/tools); [prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts); [completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion); [logging (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging), [logging (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging); [pagination](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination); [discovery](https://modelcontextprotocol.io/specification/2026-07-28/server/discover); [base protocol / error codes](https://modelcontextprotocol.io/specification/2026-07-28/basic/index).
112
221
 
113
222
  ## Security notes
114
223
 
115
224
  - **Local only.** The server binds to `127.0.0.1`. Requests whose `Host` is not `localhost` / `127.0.0.1` / `[::1]` are refused with `403` (DNS-rebinding defence), as are requests with a non-local `Origin` header — so web pages in your browser cannot call it.
116
- - **Tokens.** Every request needs `Authorization: Bearer <token>`; missing or unknown tokens get `401`. Tokens are 192-bit random values; only SHA-256 hashes are stored in `~/.dsh/nl2sql/state.json`, compared in constant time. Revoke a token in the page to cut a client off immediately. "Last used" is shown per token.
117
- - **Least privilege.** Prefer member- or role-scoped tokens for tools you do not fully trust. Owner tokens can read everything the page can (still read-only and policy-filtered).
118
- - **Data leaving the machine.** Whatever the MCP client receives is sent to *its* model provider. Use the privacy setting (Admin → Settings → Privacy, or per data source) to send only statistics (`summary`) or nothing (`none`) instead of rows.
119
- - **Audit.** History lists every MCP query with source `mcp`, the token name and the actor; refusals are logged as `blocked`.
225
+ - **Tokens.** Every request needs `Authorization: Bearer <token>`; missing or unknown tokens get `401`. Tokens are 192-bit random values; only SHA-256 hashes are stored in `~/.dsh/nl2sql/state.json`, compared in constant time. Revoke a token to cut a client off immediately (its sessions, streams and in-flight calls end too); **rotate** to replace a leaked secret while keeping scope and limits. "Last used" is shown per token.
226
+ - **Expiry.** A token past its `expiresAt` gets `401` with `token "…" expired at …` and `WWW-Authenticate: Bearer error="invalid_token"`. Extend it with Edit.
227
+ - **Least privilege.** Prefer member- or role-scoped tokens for tools you do not fully trust, and narrow them with a **tool allowlist** (`tools/list` only lists allowed tools; `tools/call` of others → `-32602`). Owner tokens can read everything the page can (still read-only and policy-filtered).
228
+ - **Rate limits.** A per-token limit (requests per minute, sliding window) answers `429` with `Retry-After` once exceeded.
229
+ - **Data leaving the machine.** Whatever the MCP client receives is sent to *its* model provider. Use the privacy setting (Admin → Settings → Privacy, or per data source) to send only statistics (`summary`) or nothing (`none`) instead of rows; the `nl2sql://result/…` CSV follows the same setting.
230
+ - **Audit.** History lists every MCP query with source `mcp`, the token name and the actor; refusals are logged as `blocked`, cancellations as `cancelled`. The per-token **call log** (Admin → MCP access, RPC `mcpCallLog`) keeps time, method / tool, data source, duration, status (`ok` / `error` / `cancelled` / `rate-limited`) and row count — never result rows; SQL is cut at 2000 characters. It is retained like other logs (5000 entries).
package/drivers.js CHANGED
@@ -193,10 +193,12 @@ class MysqlDriver {
193
193
  * once that many arrived the pooled connection is destroyed (never returned
194
194
  * to the pool mid-result), which also aborts the statement on the server.
195
195
  */
196
- async stream(sql, { timeoutMs, maxRows }) {
196
+ async stream(sql, { timeoutMs, maxRows, signal }) {
197
197
  const limit = Number.isFinite(maxRows) && maxRows >= 0 ? maxRows + 1 : Infinity
198
+ if (signal?.aborted) throw new DriverError('query/cancelled', '查询已取消')
198
199
  const conn = await this.pool.getConnection()
199
200
  const core = conn.connection
201
+ const self = this
200
202
  let destroyed = false
201
203
  const destroy = () => { if (!destroyed) { destroyed = true; try { conn.destroy() } catch {} } }
202
204
  // Late protocol errors after an intentional destroy must not crash the process.
@@ -230,8 +232,19 @@ class MysqlDriver {
230
232
  q.on('end', () => {
231
233
  if (settled) return
232
234
  settled = true
235
+ signal?.removeEventListener('abort', onAbort)
233
236
  resolve({ rows, fields, aborted: false })
234
237
  })
238
+ // Cancellation (MCP notifications/cancelled): KILL QUERY on the server, then drop the connection.
239
+ function onAbort() {
240
+ if (settled) return
241
+ settled = true
242
+ const threadId = core.threadId
243
+ destroy()
244
+ self.pool.query(`KILL QUERY ${Number(threadId)}`).catch(() => {})
245
+ reject(new DriverError('query/cancelled', '查询已取消'))
246
+ }
247
+ signal?.addEventListener('abort', onAbort, { once: true })
235
248
  })
236
249
  if (!out.aborted) {
237
250
  await conn.query('ROLLBACK').catch(() => {})
@@ -254,8 +267,8 @@ class MysqlDriver {
254
267
  }
255
268
  }
256
269
 
257
- async query(sql, { timeoutMs, maxRows }) {
258
- const { rows, fields } = await this.stream(sql, { timeoutMs, maxRows })
270
+ async query(sql, { timeoutMs, maxRows, signal }) {
271
+ const { rows, fields } = await this.stream(sql, { timeoutMs, maxRows, signal })
259
272
  return {
260
273
  columns: fields.map((f, i) => ({
261
274
  name: f.name,