@hedeqiang/dsh-nl2sql 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/bin/nl2sql-mcp.mjs +117 -40
- package/docs/mcp.md +152 -41
- package/drivers.js +16 -3
- package/lib/client.js +197 -14
- package/package.json +2 -2
- package/service/mcp.js +966 -100
- package/service/query.js +11 -3
- package/service/shared.js +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
|
|
20
|
+
## [0.13.1] - 2026-11-12
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `npx -p @hedeqiang/dsh-nl2sql nl2sql-mcp` warned about an unsupported Node version: `engines.node` is now `>=18` (the bridge's requirement; the plugin itself runs on the Node bundled with DeepSeek Harness).
|
|
24
|
+
|
|
8
25
|
## [0.13.0] - 2026-11-12
|
|
9
26
|
|
|
10
27
|
### Added
|
|
@@ -122,7 +139,9 @@ These features are tested with local databases and fake IM / Lark servers; real
|
|
|
122
139
|
|
|
123
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.
|
|
124
141
|
|
|
125
|
-
[Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.
|
|
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
|
|
144
|
+
[0.13.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.0...v0.13.1
|
|
126
145
|
[0.13.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.2...v0.13.0
|
|
127
146
|
[0.12.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.1...v0.12.2
|
|
128
147
|
[0.12.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.0...v0.12.1
|
package/bin/nl2sql-mcp.mjs
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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--;
|
|
177
|
+
forward(msg).catch(e => log('forward failed:', e.message)).finally(() => { pending--; finish() })
|
|
101
178
|
}
|
|
102
179
|
})
|
|
103
|
-
process.stdin.on('end', () => { ended = true;
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
### VS Code
|
|
68
82
|
|
|
69
|
-
|
|
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
|
|
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
|
|
171
|
+
**Resources** (`resources/list` is paginated; `resources/templates/list` lists the templates):
|
|
104
172
|
|
|
105
|
-
|
|
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
|
|
110
|
-
- Dual-era:
|
|
111
|
-
|
|
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
|
|
117
|
-
- **
|
|
118
|
-
- **
|
|
119
|
-
- **
|
|
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,
|