@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/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 +233 -20
- package/package.json +1 -1
- package/service/mcp.js +966 -100
- package/service/query.js +11 -3
- package/service/shared.js +9 -0
package/lib/client.js
CHANGED
|
@@ -17,6 +17,15 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
// ------------------------------------------------------------ locale
|
|
18
18
|
|
|
19
19
|
const zh = {
|
|
20
|
+
'mcp.client.claudeCode': 'Claude Code', 'mcp.client.cline': 'Cline', 'mcp.client.windsurf': 'Windsurf', 'mcp.client.codex': 'Codex CLI', 'mcp.client.gemini': 'Gemini CLI', 'mcp.client.zed': 'Zed', 'mcp.client.continue': 'Continue',
|
|
21
|
+
'mcp.where.claudeCode': '在终端运行(HTTP 直连;下方为 stdio 桥接写法)。', 'mcp.where.cline': '写入 cline_mcp_settings.json(Cline 面板 → MCP Servers → Configure)。', 'mcp.where.windsurf': '写入 Windsurf 的 mcp_config.json(Cascade → MCP → 打开配置文件)。', 'mcp.where.codex': '写入 ~/.codex/config.toml(或项目的 .codex/config.toml)。', 'mcp.where.gemini': '写入 ~/.gemini/settings.json 或项目的 .gemini/settings.json。', 'mcp.where.zed': '写入 Zed 的 settings.json。', 'mcp.where.continue': '保存为工作区 .continue/mcpServers/nl2sql.yaml。',
|
|
22
|
+
'mcp.test': '测试连接', 'mcp.testOk': '连接正常:发现 {n} 个工具。', 'mcp.testFail': '连接失败:{error}', 'mcp.testFailSteps': '连接测试未通过:',
|
|
23
|
+
'mcp.expiry': '过期时间(可选)', 'mcp.noExpiry': '不过期', 'mcp.expiryHint': '到期当天结束后令牌失效(401)。', 'mcp.toolsAllow': '允许的工具', 'mcp.allTools': '全部工具', 'mcp.rateLimit': '频率限制(可选)', 'mcp.unlimited': '不限', 'mcp.perMinute': '次 / 分钟,超过返回 429',
|
|
24
|
+
'mcp.err.tools': '请至少勾选一个工具', 'mcp.edit': '编辑', 'mcp.editTitle': '编辑令牌「{name}」', 'mcp.save': '保存', 'mcp.saved': '已保存',
|
|
25
|
+
'mcp.rotate': '轮换密钥', 'mcp.rotateConfirm': '轮换令牌「{name}」的密钥?', 'mcp.rotateHint': '旧密钥立即失效,新密钥只显示一次;权限和限制保持不变。', 'mcp.rotated': '已生成新密钥',
|
|
26
|
+
'mcp.col.limits': '限制', 'mcp.noLimits': '无', 'mcp.expired': '已过期', 'mcp.expiresOn': '{date} 到期', 'mcp.toolsN': '{n} 个工具', 'mcp.rateN': '{n} 次/分',
|
|
27
|
+
'mcp.callLog': '调用记录', 'mcp.log.token': '令牌', 'mcp.log.allTokens': '全部令牌', 'mcp.log.status': '状态', 'mcp.log.all': '全部', 'mcp.log.filter': '按工具或数据源筛选', 'mcp.log.refresh': '刷新', 'mcp.log.empty': '还没有调用记录', 'mcp.log.time': '时间', 'mcp.log.tool': '工具 / 方法', 'mcp.log.datasource': '数据源', 'mcp.log.ms': '耗时 ms', 'mcp.log.rows': '行数',
|
|
28
|
+
'hist.status.cancelled': '已取消', 'mcp.log.status.ok': '成功', 'mcp.log.status.error': '失败', 'mcp.log.status.cancelled': '已取消', 'mcp.log.status.rate-limited': '限流',
|
|
20
29
|
'admin.group.insight': "洞察与自动化",
|
|
21
30
|
'admin.group.connect': "集成",
|
|
22
31
|
"fq.title": "数据新鲜度",
|
|
@@ -1169,6 +1178,15 @@ window.__ModuleLoader__.load({
|
|
|
1169
1178
|
'policy.tablesLabel': '可访问的表',
|
|
1170
1179
|
}
|
|
1171
1180
|
const en = {
|
|
1181
|
+
'mcp.client.claudeCode': 'Claude Code', 'mcp.client.cline': 'Cline', 'mcp.client.windsurf': 'Windsurf', 'mcp.client.codex': 'Codex CLI', 'mcp.client.gemini': 'Gemini CLI', 'mcp.client.zed': 'Zed', 'mcp.client.continue': 'Continue',
|
|
1182
|
+
'mcp.where.claudeCode': 'Run in a terminal (HTTP; the stdio bridge variant is below).', 'mcp.where.cline': 'Add to cline_mcp_settings.json (Cline panel → MCP Servers → Configure).', 'mcp.where.windsurf': 'Add to Windsurf\'s mcp_config.json (Cascade → MCP → open config file).', 'mcp.where.codex': 'Add to ~/.codex/config.toml (or the project\'s .codex/config.toml).', 'mcp.where.gemini': 'Add to ~/.gemini/settings.json or the project\'s .gemini/settings.json.', 'mcp.where.zed': 'Add to Zed\'s settings.json.', 'mcp.where.continue': 'Save as .continue/mcpServers/nl2sql.yaml in the workspace.',
|
|
1183
|
+
'mcp.test': 'Test connection', 'mcp.testOk': 'Connected: {n} tools discovered.', 'mcp.testFail': 'Connection failed: {error}', 'mcp.testFailSteps': 'Connection test failed:',
|
|
1184
|
+
'mcp.expiry': 'Expires (optional)', 'mcp.noExpiry': 'Never', 'mcp.expiryHint': 'The token stops working (401) after the end of that day.', 'mcp.toolsAllow': 'Allowed tools', 'mcp.allTools': 'All tools', 'mcp.rateLimit': 'Rate limit (optional)', 'mcp.unlimited': 'unlimited', 'mcp.perMinute': 'requests / minute; above it → 429',
|
|
1185
|
+
'mcp.err.tools': 'Select at least one tool', 'mcp.edit': 'Edit', 'mcp.editTitle': 'Edit token "{name}"', 'mcp.save': 'Save', 'mcp.saved': 'Saved',
|
|
1186
|
+
'mcp.rotate': 'Rotate secret', 'mcp.rotateConfirm': 'Rotate the secret of token "{name}"?', 'mcp.rotateHint': 'The old secret stops working at once; the new one is shown once. Scope and limits stay the same.', 'mcp.rotated': 'New secret generated',
|
|
1187
|
+
'mcp.col.limits': 'Limits', 'mcp.noLimits': 'none', 'mcp.expired': 'Expired', 'mcp.expiresOn': 'expires {date}', 'mcp.toolsN': '{n} tools', 'mcp.rateN': '{n}/min',
|
|
1188
|
+
'mcp.callLog': 'Call log', 'mcp.log.token': 'Token', 'mcp.log.allTokens': 'All tokens', 'mcp.log.status': 'Status', 'mcp.log.all': 'All', 'mcp.log.filter': 'Filter by tool or data source', 'mcp.log.refresh': 'Refresh', 'mcp.log.empty': 'No calls yet', 'mcp.log.time': 'Time', 'mcp.log.tool': 'Tool / method', 'mcp.log.datasource': 'Data source', 'mcp.log.ms': 'ms', 'mcp.log.rows': 'Rows',
|
|
1189
|
+
'hist.status.cancelled': 'Cancelled', 'mcp.log.status.ok': 'OK', 'mcp.log.status.error': 'Failed', 'mcp.log.status.cancelled': 'Cancelled', 'mcp.log.status.rate-limited': 'Rate limited',
|
|
1172
1190
|
'admin.group.insight': "Insight & automation",
|
|
1173
1191
|
'admin.group.connect': "Integrations",
|
|
1174
1192
|
"fq.title": "Data freshness",
|
|
@@ -2338,6 +2356,17 @@ window.__ModuleLoader__.load({
|
|
|
2338
2356
|
.n2s-fq-summary .n2s-fq-score{min-width:48px;height:34px;font-size:17px}
|
|
2339
2357
|
.n2s-adminnav-group{margin:14px 12px 4px;font-size:11.5px;letter-spacing:.02em;color:var(--n2s-faint)}
|
|
2340
2358
|
.n2s-adminnav-sep{height:.5px;margin:10px 12px;background:var(--n2s-line)}
|
|
2359
|
+
.n2s-mcp-tabs{flex-wrap:wrap}
|
|
2360
|
+
.n2s-mcp-limits{display:grid;gap:12px;padding-top:4px;border-top:1px dashed var(--n2s-border)}
|
|
2361
|
+
.n2s-mcp-toolgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:4px 12px;margin-top:4px;font-size:12.5px}
|
|
2362
|
+
.n2s-mcp-editor{display:grid;gap:12px;border-color:var(--n2s-accent)}
|
|
2363
|
+
.n2s-mcp-actions{text-align:right;white-space:nowrap}
|
|
2364
|
+
.n2s-mcp-actions .n2s-btn{margin-left:4px}
|
|
2365
|
+
.n2s-mcp-tokens .n2s-badge{margin:0 4px 2px 0}
|
|
2366
|
+
.n2s-mcp-selftest{margin-top:6px}
|
|
2367
|
+
.n2s-mcp-steps{margin:6px 0 0;padding-left:18px;font-size:12.5px}
|
|
2368
|
+
.n2s-mcp-log .n2s-select{max-width:200px}
|
|
2369
|
+
.n2s-mcp-logtable td{vertical-align:middle}
|
|
2341
2370
|
/* ---- dashboard v2 ---- */
|
|
2342
2371
|
.n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
|
|
2343
2372
|
.n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
|
|
@@ -2807,6 +2836,26 @@ window.__ModuleLoader__.load({
|
|
|
2807
2836
|
}
|
|
2808
2837
|
// ----------------------------------------------------------- charts
|
|
2809
2838
|
|
|
2839
|
+
// ECharts is loaded in a sandboxed wrapper (lib/client.echarts.js) whose `window` is an empty
|
|
2840
|
+
// object, so it cannot read the screen's devicePixelRatio and would draw at 1x (blurry on
|
|
2841
|
+
// Retina / HiDPI). Every chart passes the real ratio explicitly and re-inits when it changes
|
|
2842
|
+
// (moving between screens, browser zoom).
|
|
2843
|
+
const chartDpr = () => Math.max(1, Math.min(3, (typeof window !== 'undefined' && window.devicePixelRatio) || 1))
|
|
2844
|
+
function initChart(echarts, el, opts = {}) {
|
|
2845
|
+
return echarts.init(el, null, { renderer: 'canvas', devicePixelRatio: chartDpr(), ...opts })
|
|
2846
|
+
}
|
|
2847
|
+
/** Call `onChange` when the device pixel ratio changes; returns a cleanup. */
|
|
2848
|
+
function watchDpr(onChange) {
|
|
2849
|
+
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
|
|
2850
|
+
let mq
|
|
2851
|
+
const listen = () => {
|
|
2852
|
+
mq = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`)
|
|
2853
|
+
mq.addEventListener?.('change', handler, { once: true })
|
|
2854
|
+
}
|
|
2855
|
+
const handler = () => { onChange(); listen() }
|
|
2856
|
+
listen()
|
|
2857
|
+
return () => mq?.removeEventListener?.('change', handler)
|
|
2858
|
+
}
|
|
2810
2859
|
let echartsPromise
|
|
2811
2860
|
function loadEcharts() {
|
|
2812
2861
|
echartsPromise ??= require.async('./client.echarts.js')
|
|
@@ -2913,17 +2962,27 @@ window.__ModuleLoader__.load({
|
|
|
2913
2962
|
useEffect(() => {
|
|
2914
2963
|
let disposed = false
|
|
2915
2964
|
let observer
|
|
2965
|
+
let stopDpr
|
|
2916
2966
|
loadEcharts().then((echarts) => {
|
|
2917
2967
|
if (disposed || !ref.current) return
|
|
2918
|
-
|
|
2919
|
-
|
|
2968
|
+
const create = () => {
|
|
2969
|
+
const prev = instance.current
|
|
2970
|
+
const option = prev?.getOption?.()
|
|
2971
|
+
prev?.dispose()
|
|
2972
|
+
instance.current = initChart(echarts, ref.current)
|
|
2973
|
+
instance.current.on('click', (p) => { if (pointRef.current && p.name !== undefined && p.name !== '') pointRef.current(p.name) })
|
|
2974
|
+
if (option) instance.current.setOption(option, true)
|
|
2975
|
+
onInstance?.(instance.current)
|
|
2976
|
+
}
|
|
2977
|
+
create()
|
|
2978
|
+
stopDpr = watchDpr(() => { if (!disposed && ref.current) create() })
|
|
2920
2979
|
observer = new ResizeObserver(() => instance.current?.resize())
|
|
2921
2980
|
observer.observe(ref.current)
|
|
2922
2981
|
setReady(true)
|
|
2923
|
-
onInstance?.(instance.current)
|
|
2924
2982
|
}, (e) => setError(e.message))
|
|
2925
2983
|
return () => {
|
|
2926
2984
|
disposed = true
|
|
2985
|
+
stopDpr?.()
|
|
2927
2986
|
observer?.disconnect()
|
|
2928
2987
|
instance.current?.dispose()
|
|
2929
2988
|
instance.current = null
|
|
@@ -3066,7 +3125,7 @@ window.__ModuleLoader__.load({
|
|
|
3066
3125
|
const [flash, setFlash] = useFlash()
|
|
3067
3126
|
const copySql = () => { navigator.clipboard?.writeText(result.sql); setFlash(t('result.copied')) }
|
|
3068
3127
|
const exportPng = () => {
|
|
3069
|
-
const url = chartInstance?.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: cssVar(document.body, '--dsw-alias-bg-base', '#fff') })
|
|
3128
|
+
const url = chartInstance?.getDataURL({ type: 'png', pixelRatio: Math.max(2, chartDpr()), backgroundColor: cssVar(document.body, '--dsw-alias-bg-base', '#fff') })
|
|
3070
3129
|
if (url) fetch(url).then(r => r.blob()).then(b => download(`${safeName(result.title)}.png`, b))
|
|
3071
3130
|
}
|
|
3072
3131
|
const isChart = chart.type !== 'table' && chart.type !== 'kpi'
|
|
@@ -3732,7 +3791,7 @@ window.__ModuleLoader__.load({
|
|
|
3732
3791
|
let observer
|
|
3733
3792
|
loadEcharts().then((echarts) => {
|
|
3734
3793
|
if (disposed || !ref.current) return
|
|
3735
|
-
chart = echarts
|
|
3794
|
+
chart = initChart(echarts, ref.current)
|
|
3736
3795
|
observer = new ResizeObserver(() => chart?.resize())
|
|
3737
3796
|
observer.observe(ref.current)
|
|
3738
3797
|
const el = ref.current
|
|
@@ -7198,7 +7257,7 @@ window.__ModuleLoader__.load({
|
|
|
7198
7257
|
let observer
|
|
7199
7258
|
loadEcharts().then((echarts) => {
|
|
7200
7259
|
if (disposed || !ref.current) return
|
|
7201
|
-
inst.current = echarts
|
|
7260
|
+
inst.current = initChart(echarts, ref.current)
|
|
7202
7261
|
observer = new ResizeObserver(() => inst.current?.resize())
|
|
7203
7262
|
observer.observe(ref.current)
|
|
7204
7263
|
try { inst.current.setOption(proForecastOption(forecast, ref.current, t), true) } catch (e) { setError(e.message) }
|
|
@@ -7696,18 +7755,40 @@ window.__ModuleLoader__.load({
|
|
|
7696
7755
|
|
|
7697
7756
|
extend.adminSections.push({ id: 'bitable', label: (t) => t('pre.bt.nav'), icon: () => Icon.table(15), Component: PreBitableSection })
|
|
7698
7757
|
// ------------------------------------------------------------ MCP access (admin section)
|
|
7699
|
-
// Enable the local MCP server, manage bearer tokens (shown once
|
|
7700
|
-
//
|
|
7758
|
+
// Enable the local MCP server, manage bearer tokens (shown once; expiry, tool allowlist,
|
|
7759
|
+
// rate limit, rotation), test the connection, read the per-token call log, and copy setup
|
|
7760
|
+
// snippets for ten MCP clients (formats verified against each client's docs, see docs/mcp.md).
|
|
7701
7761
|
|
|
7702
7762
|
const MCP_PKG = '@hedeqiang/dsh-nl2sql'
|
|
7763
|
+
const MCP_CLIENTS = ['claude', 'claudeCode', 'cursor', 'vscode', 'cline', 'windsurf', 'codex', 'gemini', 'zed', 'continue']
|
|
7703
7764
|
|
|
7704
7765
|
/** Config snippets for a token (placeholder when the token value is no longer known). */
|
|
7705
7766
|
function mcpSnippets(url, token) {
|
|
7706
7767
|
const tok = token || '<YOUR_TOKEN>'
|
|
7768
|
+
const auth = { Authorization: `Bearer ${tok}` }
|
|
7769
|
+
const json = (o) => JSON.stringify(o, null, 2)
|
|
7770
|
+
const stdio = { command: 'npx', args: ['-y', '-p', MCP_PKG, 'nl2sql-mcp'], env: { NL2SQL_MCP_URL: url, NL2SQL_MCP_TOKEN: tok } }
|
|
7707
7771
|
return {
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7772
|
+
// https://modelcontextprotocol.io/docs/develop/connect-local-servers
|
|
7773
|
+
claude: json({ mcpServers: { nl2sql: stdio } }),
|
|
7774
|
+
// https://code.claude.com/docs/en/mcp
|
|
7775
|
+
claudeCode: `claude mcp add --transport http nl2sql ${url} \\\n --header "Authorization: Bearer ${tok}"\n\n# stdio variant (bridge)\nclaude mcp add --transport stdio --env NL2SQL_MCP_URL=${url} --env NL2SQL_MCP_TOKEN=${tok} nl2sql -- npx -y -p ${MCP_PKG} nl2sql-mcp`,
|
|
7776
|
+
// https://cursor.com/docs/mcp
|
|
7777
|
+
cursor: json({ mcpServers: { nl2sql: { url, headers: auth } } }),
|
|
7778
|
+
// https://code.visualstudio.com/docs/agents/reference/mcp-configuration
|
|
7779
|
+
vscode: json({ servers: { nl2sql: { type: 'http', url, headers: auth } } }),
|
|
7780
|
+
// https://docs.cline.bot/mcp/configuring-mcp-servers
|
|
7781
|
+
cline: json({ mcpServers: { nl2sql: { type: 'streamableHttp', url, headers: auth, disabled: false } } }),
|
|
7782
|
+
// https://docs.devin.ai/windsurf/plugins/cascade/mcp
|
|
7783
|
+
windsurf: json({ mcpServers: { nl2sql: { serverUrl: url, headers: auth } } }),
|
|
7784
|
+
// https://developers.openai.com/codex/mcp
|
|
7785
|
+
codex: `[mcp_servers.nl2sql]\nurl = "${url}"\nhttp_headers = { "Authorization" = "Bearer ${tok}" }`,
|
|
7786
|
+
// https://geminicli.com/docs/tools/mcp-server/
|
|
7787
|
+
gemini: json({ mcpServers: { nl2sql: { httpUrl: url, headers: auth } } }),
|
|
7788
|
+
// https://zed.dev/docs/ai/mcp
|
|
7789
|
+
zed: json({ context_servers: { nl2sql: { url, headers: auth } } }),
|
|
7790
|
+
// https://docs.continue.dev/customize/deep-dives/mcp
|
|
7791
|
+
continue: `name: nl2sql\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: nl2sql\n type: streamable-http\n url: ${url}\n requestOptions:\n headers:\n Authorization: Bearer ${tok}`,
|
|
7711
7792
|
}
|
|
7712
7793
|
}
|
|
7713
7794
|
|
|
@@ -7724,10 +7805,9 @@ window.__ModuleLoader__.load({
|
|
|
7724
7805
|
function McpSnippets({ t, url, token }) {
|
|
7725
7806
|
const [tab, setTab] = useState('claude')
|
|
7726
7807
|
const snip = mcpSnippets(url, token)
|
|
7727
|
-
const where = { claude: 'mcp.where.claude', cursor: 'mcp.where.cursor', vscode: 'mcp.where.vscode' }
|
|
7728
7808
|
return h('div', { className: 'n2s-mcp-snippets' },
|
|
7729
|
-
h('div', { className: 'n2s-seg', role: 'tablist'
|
|
7730
|
-
h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(where
|
|
7809
|
+
h('div', { className: 'n2s-seg n2s-mcp-tabs', role: 'tablist', 'aria-label': t('mcp.setup') }, MCP_CLIENTS.map(k => h('button', { key: k, type: 'button', role: 'tab', 'data-client': k, 'aria-selected': tab === k, 'aria-pressed': tab === k, onClick: () => setTab(k) }, t(`mcp.client.${k}`)))),
|
|
7810
|
+
h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(`mcp.where.${tab}`)),
|
|
7731
7811
|
h('div', { className: 'n2s-mcp-code' },
|
|
7732
7812
|
h('pre', { className: 'n2s-code', 'data-snippet': tab }, snip[tab]),
|
|
7733
7813
|
h('div', { className: 'n2s-mcp-codeact' }, h(McpCopy, { t, text: snip[tab] }))),
|
|
@@ -7740,9 +7820,47 @@ window.__ModuleLoader__.load({
|
|
|
7740
7820
|
return h('span', { className: 'n2s-badge ok' }, t('mcp.scope.owner'))
|
|
7741
7821
|
}
|
|
7742
7822
|
|
|
7743
|
-
|
|
7823
|
+
const mcpDateInput = (ms) => {
|
|
7824
|
+
if (!ms) return ''
|
|
7825
|
+
const d = new Date(ms)
|
|
7826
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
7827
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
7828
|
+
}
|
|
7829
|
+
|
|
7830
|
+
/** Expiry, tool allowlist and rate limit fields. value: { expires: 'YYYY-MM-DD'|'', allTools, tools: [], rateLimit: '' }. */
|
|
7831
|
+
function McpLimitsFields({ t, value, onChange, toolNames }) {
|
|
7832
|
+
const set = (k, v) => onChange({ ...value, [k]: v })
|
|
7833
|
+
return h('div', { className: 'n2s-mcp-limits' },
|
|
7834
|
+
h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.expiry')),
|
|
7835
|
+
h('div', { className: 'n2s-row' },
|
|
7836
|
+
h('input', { type: 'date', className: 'n2s-input', style: { maxWidth: 180 }, 'aria-label': t('mcp.expiry'), value: value.expires, onChange: e => set('expires', e.target.value) }),
|
|
7837
|
+
value.expires && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => set('expires', '') }, t('mcp.noExpiry'))),
|
|
7838
|
+
h('span', { className: 'n2s-faint n2s-small' }, t('mcp.expiryHint'))),
|
|
7839
|
+
h('div', { className: 'n2s-field', role: 'group', 'aria-label': t('mcp.toolsAllow') },
|
|
7840
|
+
h('span', { className: 'n2s-flabel' }, t('mcp.toolsAllow')),
|
|
7841
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: value.allTools, onChange: e => set('allTools', e.target.checked) }), ' ', t('mcp.allTools')),
|
|
7842
|
+
!value.allTools && h('div', { className: 'n2s-mcp-toolgrid' }, (toolNames ?? []).map(n => h('label', { key: n, className: 'n2s-check n2s-mono' },
|
|
7843
|
+
h('input', { type: 'checkbox', 'data-tool': n, checked: value.tools.includes(n), onChange: e => set('tools', e.target.checked ? [...value.tools, n] : value.tools.filter(x => x !== n)) }), ' ', n)))),
|
|
7844
|
+
h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.rateLimit')),
|
|
7845
|
+
h('div', { className: 'n2s-row' },
|
|
7846
|
+
h('input', { className: 'n2s-input n2s-mono', style: { width: 110 }, inputMode: 'numeric', 'aria-label': t('mcp.rateLimit'), placeholder: t('mcp.unlimited'), value: value.rateLimit, onChange: e => set('rateLimit', e.target.value.replace(/\D/g, '').slice(0, 5)) }),
|
|
7847
|
+
h('span', { className: 'n2s-faint n2s-small' }, t('mcp.perMinute')))))
|
|
7848
|
+
}
|
|
7849
|
+
|
|
7850
|
+
/** Limits form state → RPC payload fields. */
|
|
7851
|
+
function mcpLimitsPayload(v) {
|
|
7852
|
+
return {
|
|
7853
|
+
expiresAt: v.expires ? new Date(`${v.expires}T23:59:59`).getTime() : null,
|
|
7854
|
+
tools: v.allTools ? null : v.tools,
|
|
7855
|
+
rateLimit: v.rateLimit ? Number(v.rateLimit) : null,
|
|
7856
|
+
}
|
|
7857
|
+
}
|
|
7858
|
+
const mcpLimitsOf = (tok) => ({ expires: mcpDateInput(tok?.expiresAt), allTools: !Array.isArray(tok?.tools), tools: tok?.tools ?? [], rateLimit: tok?.rateLimit ? String(tok.rateLimit) : '' })
|
|
7859
|
+
|
|
7860
|
+
function McpTokenForm({ t, datasources, toolNames, onCreated, onCancel }) {
|
|
7744
7861
|
const users = useAsync(() => call('listUsers').catch(() => []), [])
|
|
7745
7862
|
const [form, setForm] = useState({ name: '', type: 'owner', memberId: '', role: 'analyst', datasourceIds: [], rowColumn: '', rowValues: '' })
|
|
7863
|
+
const [limits, setLimits] = useState(mcpLimitsOf(null))
|
|
7746
7864
|
const [busy, setBusy] = useState(false)
|
|
7747
7865
|
const [err, setErr] = useState(null)
|
|
7748
7866
|
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
|
|
@@ -7755,8 +7873,10 @@ window.__ModuleLoader__.load({
|
|
|
7755
7873
|
if (!form.name.trim()) return setErr(t('mcp.err.name'))
|
|
7756
7874
|
if (form.type === 'member' && !form.memberId) return setErr(t('mcp.err.member'))
|
|
7757
7875
|
if (form.type === 'role' && !form.datasourceIds.length) return setErr(t('mcp.err.ds'))
|
|
7876
|
+
if (!limits.allTools && !limits.tools.length) return setErr(t('mcp.err.tools'))
|
|
7877
|
+
const extra = mcpLimitsPayload(limits)
|
|
7758
7878
|
setBusy(true)
|
|
7759
|
-
try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope })) } catch (e) { setErr(e.message) }
|
|
7879
|
+
try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope, ...Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== null)) })) } catch (e) { setErr(e.message) }
|
|
7760
7880
|
setBusy(false)
|
|
7761
7881
|
}
|
|
7762
7882
|
return h('div', { className: 'n2s-card n2s-mcp-form' },
|
|
@@ -7783,17 +7903,91 @@ window.__ModuleLoader__.load({
|
|
|
7783
7903
|
h('input', { className: 'n2s-input n2s-mono', style: { maxWidth: 180 }, 'aria-label': t('mcp.rowColumn'), placeholder: t('mcp.rowColumn'), value: form.rowColumn, onChange: e => set('rowColumn', e.target.value) }),
|
|
7784
7904
|
h('input', { className: 'n2s-input', 'aria-label': t('mcp.rowValues'), placeholder: t('mcp.rowValues'), value: form.rowValues, onChange: e => set('rowValues', e.target.value) })),
|
|
7785
7905
|
h('span', { className: 'n2s-faint n2s-small' }, t('mcp.rowRuleHint')))),
|
|
7906
|
+
h(McpLimitsFields, { t, value: limits, onChange: setLimits, toolNames }),
|
|
7786
7907
|
err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
|
|
7787
7908
|
h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
|
|
7788
7909
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
7789
7910
|
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: submit }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.create'))))
|
|
7790
7911
|
}
|
|
7791
7912
|
|
|
7913
|
+
/** Edit name / expiry / allowlist / rate limit of an existing token (the secret is never shown again). */
|
|
7914
|
+
function McpTokenEditor({ t, tok, toolNames, onSaved, onCancel }) {
|
|
7915
|
+
const [name, setName] = useState(tok.name)
|
|
7916
|
+
const [limits, setLimits] = useState(mcpLimitsOf(tok))
|
|
7917
|
+
const [busy, setBusy] = useState(false)
|
|
7918
|
+
const [err, setErr] = useState(null)
|
|
7919
|
+
const save = async () => {
|
|
7920
|
+
setErr(null)
|
|
7921
|
+
if (!name.trim()) return setErr(t('mcp.err.name'))
|
|
7922
|
+
if (!limits.allTools && !limits.tools.length) return setErr(t('mcp.err.tools'))
|
|
7923
|
+
setBusy(true)
|
|
7924
|
+
try { onSaved(await call('updateMcpToken', { id: tok.id, name: name.trim(), ...mcpLimitsPayload(limits) })) } catch (e) { setErr(e.message) }
|
|
7925
|
+
setBusy(false)
|
|
7926
|
+
}
|
|
7927
|
+
return h('div', { className: 'n2s-card n2s-mcp-editor', 'data-editing': tok.id },
|
|
7928
|
+
h('div', { className: 'n2s-sechead-title', style: { marginBottom: 10 } }, fmt(t, 'mcp.editTitle', { name: tok.name })),
|
|
7929
|
+
h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.tokenName')),
|
|
7930
|
+
h('input', { className: 'n2s-input', value: name, maxLength: 60, 'aria-label': t('mcp.tokenName'), onChange: e => setName(e.target.value) })),
|
|
7931
|
+
h(McpLimitsFields, { t, value: limits, onChange: setLimits, toolNames }),
|
|
7932
|
+
err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
|
|
7933
|
+
h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
|
|
7934
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
7935
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.save'))))
|
|
7936
|
+
}
|
|
7937
|
+
|
|
7938
|
+
/** "Test connection": mcpSelfTest (discover + tools/list + initialize over loopback with a temporary credential). */
|
|
7939
|
+
function McpSelfTest({ t, disabled }) {
|
|
7940
|
+
const [res, setRes] = useState(null)
|
|
7941
|
+
const [busy, setBusy] = useState(false)
|
|
7942
|
+
const run = async () => {
|
|
7943
|
+
setBusy(true)
|
|
7944
|
+
try { setRes(await call('mcpSelfTest')) } catch (e) { setRes({ ok: false, error: e.message, steps: [] }) }
|
|
7945
|
+
setBusy(false)
|
|
7946
|
+
}
|
|
7947
|
+
return h('div', { className: 'n2s-mcp-selftest' },
|
|
7948
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: disabled || busy, onClick: run }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.test')),
|
|
7949
|
+
res && h('div', { className: `n2s-notice ${res.ok ? 'ok' : 'err'}`, role: 'status', 'data-selftest': res.ok ? 'ok' : 'fail', style: { marginTop: 8 } },
|
|
7950
|
+
h('div', null, res.ok ? fmt(t, 'mcp.testOk', { n: res.tools?.length ?? 0 }) : (res.error ? fmt(t, 'mcp.testFail', { error: res.error }) : t('mcp.testFailSteps'))),
|
|
7951
|
+
(res.steps ?? []).length > 0 && h('ul', { className: 'n2s-mcp-steps' }, res.steps.map(s => h('li', { key: s.step }, s.ok ? '✓ ' : '✗ ', h('span', { className: 'n2s-mono' }, s.step), ` · ${s.detail} · ${s.ms} ms`)))))
|
|
7952
|
+
}
|
|
7953
|
+
|
|
7954
|
+
/** Per-token call log with a status / tool filter. */
|
|
7955
|
+
function McpCallLog({ t, tokens }) {
|
|
7956
|
+
const [tokenId, setTokenId] = useState('')
|
|
7957
|
+
const [status, setStatus] = useState('')
|
|
7958
|
+
const [q, setQ] = useState('')
|
|
7959
|
+
const log = useAsync(() => call('mcpCallLog', { ...(tokenId ? { tokenId } : {}), ...(status ? { status } : {}), limit: 200 }), [tokenId, status])
|
|
7960
|
+
const when = (ms) => new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
7961
|
+
const rows = (log.value ?? []).filter(r => !q || `${r.tool ?? r.method} ${r.datasource ?? ''}`.toLowerCase().includes(q.toLowerCase()))
|
|
7962
|
+
const badge = (s) => h('span', { className: `n2s-badge ${s === 'ok' ? 'ok' : s === 'cancelled' || s === 'rate-limited' ? 'warn' : 'err'}` }, t(`mcp.log.status.${s}`) ?? s)
|
|
7963
|
+
return h('div', { className: 'n2s-card n2s-mcp-log' },
|
|
7964
|
+
h('div', { className: 'n2s-row', style: { flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
|
|
7965
|
+
h('select', { className: 'n2s-select', 'aria-label': t('mcp.log.token'), value: tokenId, onChange: e => setTokenId(e.target.value) },
|
|
7966
|
+
h('option', { value: '' }, t('mcp.log.allTokens')), tokens.map(tk => h('option', { key: tk.id, value: tk.id }, tk.name))),
|
|
7967
|
+
h('div', { className: 'n2s-seg', role: 'group', 'aria-label': t('mcp.log.status') }, ['', 'ok', 'error', 'cancelled', 'rate-limited'].map(v => h('button', { key: v || 'all', type: 'button', 'aria-pressed': status === v, onClick: () => setStatus(v) }, v ? t(`mcp.log.status.${v}`) : t('mcp.log.all')))),
|
|
7968
|
+
h('input', { className: 'n2s-input', style: { maxWidth: 200 }, 'aria-label': t('mcp.log.filter'), placeholder: t('mcp.log.filter'), value: q, onChange: e => setQ(e.target.value) }),
|
|
7969
|
+
h('span', { className: 'n2s-grow' }),
|
|
7970
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => log.reload() }, t('mcp.log.refresh'))),
|
|
7971
|
+
rows.length === 0
|
|
7972
|
+
? h('div', { className: 'n2s-faint n2s-small' }, log.value ? t('mcp.log.empty') : '…')
|
|
7973
|
+
: h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-mcp-logtable' },
|
|
7974
|
+
h('thead', null, h('tr', null, [t('mcp.log.time'), t('mcp.log.token'), t('mcp.log.tool'), t('mcp.log.datasource'), t('mcp.log.ms'), t('mcp.log.rows'), t('mcp.log.status')].map((x, i) => h('th', { key: i }, x)))),
|
|
7975
|
+
h('tbody', null, rows.map(r => h('tr', { key: r.id, 'data-log-id': r.id, title: r.error ?? '' },
|
|
7976
|
+
h('td', { className: 'n2s-small' }, when(r.at)),
|
|
7977
|
+
h('td', { className: 'n2s-small' }, r.tokenName),
|
|
7978
|
+
h('td', { className: 'n2s-mono n2s-small' }, r.tool ?? r.method),
|
|
7979
|
+
h('td', { className: 'n2s-small' }, r.datasource ?? '—'),
|
|
7980
|
+
h('td', { className: 'n2s-small' }, r.ms),
|
|
7981
|
+
h('td', { className: 'n2s-small' }, r.rows ?? '—'),
|
|
7982
|
+
h('td', null, badge(r.status))))))))
|
|
7983
|
+
}
|
|
7984
|
+
|
|
7792
7985
|
function McpSection({ t, datasources }) {
|
|
7793
7986
|
const st = useAsync(() => call('mcpStatus'), [])
|
|
7794
7987
|
const [port, setPort] = useState('')
|
|
7795
7988
|
const [busy, setBusy] = useState(false)
|
|
7796
7989
|
const [creating, setCreating] = useState(false)
|
|
7990
|
+
const [editing, setEditing] = useState(null)
|
|
7797
7991
|
const [fresh, setFresh] = useState(null) // { name, token } shown once
|
|
7798
7992
|
useEffect(() => { if (st.value) setPort(String(st.value.port)) }, [st.value?.port])
|
|
7799
7993
|
const s = st.value
|
|
@@ -7807,8 +8001,18 @@ window.__ModuleLoader__.load({
|
|
|
7807
8001
|
if (!(await confirmDialog({ title: fmt(t, 'mcp.revokeConfirm', { name: tok.name }), message: t('mcp.revokeHint'), confirmLabel: t('mcp.revoke'), danger: true }))) return
|
|
7808
8002
|
try { await call('revokeMcpToken', { id: tok.id }); if (fresh?.id === tok.id) setFresh(null); notify(t('mcp.revoked'), 'ok'); st.reload() } catch (e) { notifyError(e) }
|
|
7809
8003
|
}
|
|
8004
|
+
const rotate = async (tok) => {
|
|
8005
|
+
if (!(await confirmDialog({ title: fmt(t, 'mcp.rotateConfirm', { name: tok.name }), message: t('mcp.rotateHint'), confirmLabel: t('mcp.rotate'), danger: true }))) return
|
|
8006
|
+
try { const r = await call('rotateMcpToken', { id: tok.id }); setFresh(r); notify(t('mcp.rotated'), 'ok'); st.reload() } catch (e) { notifyError(e) }
|
|
8007
|
+
}
|
|
7810
8008
|
if (!s) return st.error ? h('div', { className: 'n2s-notice err' }, st.error.message) : h('div', { className: 'n2s-skel', style: { height: 200 } })
|
|
7811
8009
|
const portDirty = port !== String(s.port)
|
|
8010
|
+
const toolNames = s.toolNames ?? []
|
|
8011
|
+
const limitsLabel = (tok) => [
|
|
8012
|
+
tok.expiresAt ? (tok.expired ? t('mcp.expired') : fmt(t, 'mcp.expiresOn', { date: new Date(tok.expiresAt).toLocaleDateString(uiLocale()) })) : null,
|
|
8013
|
+
Array.isArray(tok.tools) ? fmt(t, 'mcp.toolsN', { n: tok.tools.length }) : null,
|
|
8014
|
+
tok.rateLimit ? fmt(t, 'mcp.rateN', { n: tok.rateLimit }) : null,
|
|
8015
|
+
].filter(Boolean)
|
|
7812
8016
|
return h('div', { className: 'n2s-mcp' },
|
|
7813
8017
|
h(SectionHead, { title: t('mcp.title'), desc: t('mcp.desc') }),
|
|
7814
8018
|
h('div', { className: 'n2s-card n2s-mcp-server' },
|
|
@@ -7826,7 +8030,8 @@ window.__ModuleLoader__.load({
|
|
|
7826
8030
|
h('span', { className: 'n2s-flabel' }, t('mcp.endpoint')),
|
|
7827
8031
|
h('code', { className: 'n2s-mono', 'data-mcp-url': true }, s.url),
|
|
7828
8032
|
h(McpCopy, { t, text: s.url })),
|
|
7829
|
-
h(
|
|
8033
|
+
h(McpSelfTest, { t, disabled: !s.running }),
|
|
8034
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('mcp.localOnly'))),
|
|
7830
8035
|
fresh && h('div', { className: 'n2s-card n2s-mcp-fresh', role: 'status' },
|
|
7831
8036
|
h('div', { className: 'n2s-sechead-title' }, fmt(t, 'mcp.freshTitle', { name: fresh.name })),
|
|
7832
8037
|
h('div', { className: 'n2s-notice warn', style: { margin: '8px 0' } }, t('mcp.freshHint')),
|
|
@@ -7836,17 +8041,25 @@ window.__ModuleLoader__.load({
|
|
|
7836
8041
|
h('div', { className: 'n2s-row', style: { margin: '18px 0 8px' } },
|
|
7837
8042
|
h('div', { className: 'n2s-sechead-title n2s-grow' }, t('mcp.tokens')),
|
|
7838
8043
|
!creating && h('button', { type: 'button', className: 'n2s-btn', onClick: () => setCreating(true) }, '+ ', t('mcp.newToken'))),
|
|
7839
|
-
creating && h(McpTokenForm, { t, datasources, onCancel: () => setCreating(false), onCreated: (tok) => { setCreating(false); setFresh(tok); st.reload() } }),
|
|
8044
|
+
creating && h(McpTokenForm, { t, datasources, toolNames, onCancel: () => setCreating(false), onCreated: (tok) => { setCreating(false); setFresh(tok); st.reload() } }),
|
|
8045
|
+
editing && h(McpTokenEditor, { key: editing.id, t, tok: editing, toolNames, onCancel: () => setEditing(null), onSaved: () => { setEditing(null); notify(t('mcp.saved'), 'ok'); st.reload() } }),
|
|
7840
8046
|
s.tokens.length === 0 && !creating
|
|
7841
8047
|
? h('div', { className: 'n2s-empty' }, h('h3', null, t('mcp.noTokens')), h('div', null, t('mcp.noTokensHint')))
|
|
7842
8048
|
: s.tokens.length > 0 && h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-mcp-tokens' },
|
|
7843
|
-
h('thead', null, h('tr', null, [t('mcp.col.name'), t('mcp.col.scope'), t('mcp.col.created'), t('mcp.col.lastUsed'), ''].map((x, i) => h('th', { key: i }, x)))),
|
|
8049
|
+
h('thead', null, h('tr', null, [t('mcp.col.name'), t('mcp.col.scope'), t('mcp.col.limits'), t('mcp.col.created'), t('mcp.col.lastUsed'), ''].map((x, i) => h('th', { key: i }, x)))),
|
|
7844
8050
|
h('tbody', null, s.tokens.map(tok => h('tr', { key: tok.id, 'data-token-id': tok.id },
|
|
7845
8051
|
h('td', null, h('div', null, tok.name), h('div', { className: 'n2s-faint n2s-small n2s-mono' }, `${tok.prefix}…`)),
|
|
7846
8052
|
h('td', null, h(McpScopeLabel, { t, scope: tok.scope })),
|
|
8053
|
+
h('td', { className: 'n2s-small' }, limitsLabel(tok).length ? limitsLabel(tok).map((x, i) => h('span', { key: i, className: `n2s-badge ${tok.expired && i === 0 ? 'warn' : ''}` }, x)) : h('span', { className: 'n2s-faint' }, t('mcp.noLimits'))),
|
|
7847
8054
|
h('td', { className: 'n2s-small' }, when(tok.createdAt)),
|
|
7848
8055
|
h('td', { className: 'n2s-small' }, when(tok.lastUsedAt)),
|
|
7849
|
-
h('td', {
|
|
8056
|
+
h('td', { className: 'n2s-mcp-actions' },
|
|
8057
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => setEditing(tok) }, t('mcp.edit')),
|
|
8058
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => rotate(tok) }, t('mcp.rotate')),
|
|
8059
|
+
h('button', { type: 'button', className: 'n2s-btn small danger', onClick: () => revoke(tok) }, t('mcp.revoke')))))))),
|
|
8060
|
+
s.tokens.length > 0 && h(React.Fragment, null,
|
|
8061
|
+
h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.callLog')),
|
|
8062
|
+
h(McpCallLog, { t, tokens: s.tokens })),
|
|
7850
8063
|
h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.setup')),
|
|
7851
8064
|
h('div', { className: 'n2s-card' }, h(McpSnippets, { t, url: s.url, token: null }),
|
|
7852
8065
|
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('mcp.docsHint'))))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hedeqiang/dsh-nl2sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|