@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/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}
@@ -7696,18 +7725,40 @@ window.__ModuleLoader__.load({
7696
7725
 
7697
7726
  extend.adminSections.push({ id: 'bitable', label: (t) => t('pre.bt.nav'), icon: () => Icon.table(15), Component: PreBitableSection })
7698
7727
  // ------------------------------------------------------------ MCP access (admin section)
7699
- // Enable the local MCP server, manage bearer tokens (shown once) and their
7700
- // scopes, and copy config snippets for Claude Desktop / Cursor / VS Code.
7728
+ // Enable the local MCP server, manage bearer tokens (shown once; expiry, tool allowlist,
7729
+ // rate limit, rotation), test the connection, read the per-token call log, and copy setup
7730
+ // snippets for ten MCP clients (formats verified against each client's docs, see docs/mcp.md).
7701
7731
 
7702
7732
  const MCP_PKG = '@hedeqiang/dsh-nl2sql'
7733
+ const MCP_CLIENTS = ['claude', 'claudeCode', 'cursor', 'vscode', 'cline', 'windsurf', 'codex', 'gemini', 'zed', 'continue']
7703
7734
 
7704
7735
  /** Config snippets for a token (placeholder when the token value is no longer known). */
7705
7736
  function mcpSnippets(url, token) {
7706
7737
  const tok = token || '<YOUR_TOKEN>'
7738
+ const auth = { Authorization: `Bearer ${tok}` }
7739
+ const json = (o) => JSON.stringify(o, null, 2)
7740
+ const stdio = { command: 'npx', args: ['-y', '-p', MCP_PKG, 'nl2sql-mcp'], env: { NL2SQL_MCP_URL: url, NL2SQL_MCP_TOKEN: tok } }
7707
7741
  return {
7708
- claude: JSON.stringify({ mcpServers: { nl2sql: { command: 'npx', args: ['-y', '-p', MCP_PKG, 'nl2sql-mcp'], env: { NL2SQL_MCP_URL: url, NL2SQL_MCP_TOKEN: tok } } } }, null, 2),
7709
- cursor: JSON.stringify({ mcpServers: { nl2sql: { url, headers: { Authorization: `Bearer ${tok}` } } } }, null, 2),
7710
- vscode: JSON.stringify({ servers: { nl2sql: { type: 'http', url, headers: { Authorization: `Bearer ${tok}` } } } }, null, 2),
7742
+ // https://modelcontextprotocol.io/docs/develop/connect-local-servers
7743
+ claude: json({ mcpServers: { nl2sql: stdio } }),
7744
+ // https://code.claude.com/docs/en/mcp
7745
+ 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`,
7746
+ // https://cursor.com/docs/mcp
7747
+ cursor: json({ mcpServers: { nl2sql: { url, headers: auth } } }),
7748
+ // https://code.visualstudio.com/docs/agents/reference/mcp-configuration
7749
+ vscode: json({ servers: { nl2sql: { type: 'http', url, headers: auth } } }),
7750
+ // https://docs.cline.bot/mcp/configuring-mcp-servers
7751
+ cline: json({ mcpServers: { nl2sql: { type: 'streamableHttp', url, headers: auth, disabled: false } } }),
7752
+ // https://docs.devin.ai/windsurf/plugins/cascade/mcp
7753
+ windsurf: json({ mcpServers: { nl2sql: { serverUrl: url, headers: auth } } }),
7754
+ // https://developers.openai.com/codex/mcp
7755
+ codex: `[mcp_servers.nl2sql]\nurl = "${url}"\nhttp_headers = { "Authorization" = "Bearer ${tok}" }`,
7756
+ // https://geminicli.com/docs/tools/mcp-server/
7757
+ gemini: json({ mcpServers: { nl2sql: { httpUrl: url, headers: auth } } }),
7758
+ // https://zed.dev/docs/ai/mcp
7759
+ zed: json({ context_servers: { nl2sql: { url, headers: auth } } }),
7760
+ // https://docs.continue.dev/customize/deep-dives/mcp
7761
+ 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
7762
  }
7712
7763
  }
7713
7764
 
@@ -7724,10 +7775,9 @@ window.__ModuleLoader__.load({
7724
7775
  function McpSnippets({ t, url, token }) {
7725
7776
  const [tab, setTab] = useState('claude')
7726
7777
  const snip = mcpSnippets(url, token)
7727
- const where = { claude: 'mcp.where.claude', cursor: 'mcp.where.cursor', vscode: 'mcp.where.vscode' }
7728
7778
  return h('div', { className: 'n2s-mcp-snippets' },
7729
- h('div', { className: 'n2s-seg', role: 'tablist' }, ['claude', 'cursor', 'vscode'].map(k => h('button', { key: k, type: 'button', role: 'tab', 'aria-selected': tab === k, 'aria-pressed': tab === k, onClick: () => setTab(k) }, t(`mcp.client.${k}`)))),
7730
- h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(where[tab])),
7779
+ 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}`)))),
7780
+ h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(`mcp.where.${tab}`)),
7731
7781
  h('div', { className: 'n2s-mcp-code' },
7732
7782
  h('pre', { className: 'n2s-code', 'data-snippet': tab }, snip[tab]),
7733
7783
  h('div', { className: 'n2s-mcp-codeact' }, h(McpCopy, { t, text: snip[tab] }))),
@@ -7740,9 +7790,47 @@ window.__ModuleLoader__.load({
7740
7790
  return h('span', { className: 'n2s-badge ok' }, t('mcp.scope.owner'))
7741
7791
  }
7742
7792
 
7743
- function McpTokenForm({ t, datasources, onCreated, onCancel }) {
7793
+ const mcpDateInput = (ms) => {
7794
+ if (!ms) return ''
7795
+ const d = new Date(ms)
7796
+ const p = (n) => String(n).padStart(2, '0')
7797
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
7798
+ }
7799
+
7800
+ /** Expiry, tool allowlist and rate limit fields. value: { expires: 'YYYY-MM-DD'|'', allTools, tools: [], rateLimit: '' }. */
7801
+ function McpLimitsFields({ t, value, onChange, toolNames }) {
7802
+ const set = (k, v) => onChange({ ...value, [k]: v })
7803
+ return h('div', { className: 'n2s-mcp-limits' },
7804
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.expiry')),
7805
+ h('div', { className: 'n2s-row' },
7806
+ 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) }),
7807
+ value.expires && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => set('expires', '') }, t('mcp.noExpiry'))),
7808
+ h('span', { className: 'n2s-faint n2s-small' }, t('mcp.expiryHint'))),
7809
+ h('div', { className: 'n2s-field', role: 'group', 'aria-label': t('mcp.toolsAllow') },
7810
+ h('span', { className: 'n2s-flabel' }, t('mcp.toolsAllow')),
7811
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: value.allTools, onChange: e => set('allTools', e.target.checked) }), ' ', t('mcp.allTools')),
7812
+ !value.allTools && h('div', { className: 'n2s-mcp-toolgrid' }, (toolNames ?? []).map(n => h('label', { key: n, className: 'n2s-check n2s-mono' },
7813
+ 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)))),
7814
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.rateLimit')),
7815
+ h('div', { className: 'n2s-row' },
7816
+ 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)) }),
7817
+ h('span', { className: 'n2s-faint n2s-small' }, t('mcp.perMinute')))))
7818
+ }
7819
+
7820
+ /** Limits form state → RPC payload fields. */
7821
+ function mcpLimitsPayload(v) {
7822
+ return {
7823
+ expiresAt: v.expires ? new Date(`${v.expires}T23:59:59`).getTime() : null,
7824
+ tools: v.allTools ? null : v.tools,
7825
+ rateLimit: v.rateLimit ? Number(v.rateLimit) : null,
7826
+ }
7827
+ }
7828
+ const mcpLimitsOf = (tok) => ({ expires: mcpDateInput(tok?.expiresAt), allTools: !Array.isArray(tok?.tools), tools: tok?.tools ?? [], rateLimit: tok?.rateLimit ? String(tok.rateLimit) : '' })
7829
+
7830
+ function McpTokenForm({ t, datasources, toolNames, onCreated, onCancel }) {
7744
7831
  const users = useAsync(() => call('listUsers').catch(() => []), [])
7745
7832
  const [form, setForm] = useState({ name: '', type: 'owner', memberId: '', role: 'analyst', datasourceIds: [], rowColumn: '', rowValues: '' })
7833
+ const [limits, setLimits] = useState(mcpLimitsOf(null))
7746
7834
  const [busy, setBusy] = useState(false)
7747
7835
  const [err, setErr] = useState(null)
7748
7836
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
@@ -7755,8 +7843,10 @@ window.__ModuleLoader__.load({
7755
7843
  if (!form.name.trim()) return setErr(t('mcp.err.name'))
7756
7844
  if (form.type === 'member' && !form.memberId) return setErr(t('mcp.err.member'))
7757
7845
  if (form.type === 'role' && !form.datasourceIds.length) return setErr(t('mcp.err.ds'))
7846
+ if (!limits.allTools && !limits.tools.length) return setErr(t('mcp.err.tools'))
7847
+ const extra = mcpLimitsPayload(limits)
7758
7848
  setBusy(true)
7759
- try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope })) } catch (e) { setErr(e.message) }
7849
+ 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
7850
  setBusy(false)
7761
7851
  }
7762
7852
  return h('div', { className: 'n2s-card n2s-mcp-form' },
@@ -7783,17 +7873,91 @@ window.__ModuleLoader__.load({
7783
7873
  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
7874
  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
7875
  h('span', { className: 'n2s-faint n2s-small' }, t('mcp.rowRuleHint')))),
7876
+ h(McpLimitsFields, { t, value: limits, onChange: setLimits, toolNames }),
7786
7877
  err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
7787
7878
  h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
7788
7879
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7789
7880
  h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: submit }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.create'))))
7790
7881
  }
7791
7882
 
7883
+ /** Edit name / expiry / allowlist / rate limit of an existing token (the secret is never shown again). */
7884
+ function McpTokenEditor({ t, tok, toolNames, onSaved, onCancel }) {
7885
+ const [name, setName] = useState(tok.name)
7886
+ const [limits, setLimits] = useState(mcpLimitsOf(tok))
7887
+ const [busy, setBusy] = useState(false)
7888
+ const [err, setErr] = useState(null)
7889
+ const save = async () => {
7890
+ setErr(null)
7891
+ if (!name.trim()) return setErr(t('mcp.err.name'))
7892
+ if (!limits.allTools && !limits.tools.length) return setErr(t('mcp.err.tools'))
7893
+ setBusy(true)
7894
+ try { onSaved(await call('updateMcpToken', { id: tok.id, name: name.trim(), ...mcpLimitsPayload(limits) })) } catch (e) { setErr(e.message) }
7895
+ setBusy(false)
7896
+ }
7897
+ return h('div', { className: 'n2s-card n2s-mcp-editor', 'data-editing': tok.id },
7898
+ h('div', { className: 'n2s-sechead-title', style: { marginBottom: 10 } }, fmt(t, 'mcp.editTitle', { name: tok.name })),
7899
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.tokenName')),
7900
+ h('input', { className: 'n2s-input', value: name, maxLength: 60, 'aria-label': t('mcp.tokenName'), onChange: e => setName(e.target.value) })),
7901
+ h(McpLimitsFields, { t, value: limits, onChange: setLimits, toolNames }),
7902
+ err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
7903
+ h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
7904
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7905
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.save'))))
7906
+ }
7907
+
7908
+ /** "Test connection": mcpSelfTest (discover + tools/list + initialize over loopback with a temporary credential). */
7909
+ function McpSelfTest({ t, disabled }) {
7910
+ const [res, setRes] = useState(null)
7911
+ const [busy, setBusy] = useState(false)
7912
+ const run = async () => {
7913
+ setBusy(true)
7914
+ try { setRes(await call('mcpSelfTest')) } catch (e) { setRes({ ok: false, error: e.message, steps: [] }) }
7915
+ setBusy(false)
7916
+ }
7917
+ return h('div', { className: 'n2s-mcp-selftest' },
7918
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: disabled || busy, onClick: run }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.test')),
7919
+ res && h('div', { className: `n2s-notice ${res.ok ? 'ok' : 'err'}`, role: 'status', 'data-selftest': res.ok ? 'ok' : 'fail', style: { marginTop: 8 } },
7920
+ 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'))),
7921
+ (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`)))))
7922
+ }
7923
+
7924
+ /** Per-token call log with a status / tool filter. */
7925
+ function McpCallLog({ t, tokens }) {
7926
+ const [tokenId, setTokenId] = useState('')
7927
+ const [status, setStatus] = useState('')
7928
+ const [q, setQ] = useState('')
7929
+ const log = useAsync(() => call('mcpCallLog', { ...(tokenId ? { tokenId } : {}), ...(status ? { status } : {}), limit: 200 }), [tokenId, status])
7930
+ const when = (ms) => new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })
7931
+ const rows = (log.value ?? []).filter(r => !q || `${r.tool ?? r.method} ${r.datasource ?? ''}`.toLowerCase().includes(q.toLowerCase()))
7932
+ const badge = (s) => h('span', { className: `n2s-badge ${s === 'ok' ? 'ok' : s === 'cancelled' || s === 'rate-limited' ? 'warn' : 'err'}` }, t(`mcp.log.status.${s}`) ?? s)
7933
+ return h('div', { className: 'n2s-card n2s-mcp-log' },
7934
+ h('div', { className: 'n2s-row', style: { flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
7935
+ h('select', { className: 'n2s-select', 'aria-label': t('mcp.log.token'), value: tokenId, onChange: e => setTokenId(e.target.value) },
7936
+ h('option', { value: '' }, t('mcp.log.allTokens')), tokens.map(tk => h('option', { key: tk.id, value: tk.id }, tk.name))),
7937
+ 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')))),
7938
+ 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) }),
7939
+ h('span', { className: 'n2s-grow' }),
7940
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => log.reload() }, t('mcp.log.refresh'))),
7941
+ rows.length === 0
7942
+ ? h('div', { className: 'n2s-faint n2s-small' }, log.value ? t('mcp.log.empty') : '…')
7943
+ : h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-mcp-logtable' },
7944
+ 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)))),
7945
+ h('tbody', null, rows.map(r => h('tr', { key: r.id, 'data-log-id': r.id, title: r.error ?? '' },
7946
+ h('td', { className: 'n2s-small' }, when(r.at)),
7947
+ h('td', { className: 'n2s-small' }, r.tokenName),
7948
+ h('td', { className: 'n2s-mono n2s-small' }, r.tool ?? r.method),
7949
+ h('td', { className: 'n2s-small' }, r.datasource ?? '—'),
7950
+ h('td', { className: 'n2s-small' }, r.ms),
7951
+ h('td', { className: 'n2s-small' }, r.rows ?? '—'),
7952
+ h('td', null, badge(r.status))))))))
7953
+ }
7954
+
7792
7955
  function McpSection({ t, datasources }) {
7793
7956
  const st = useAsync(() => call('mcpStatus'), [])
7794
7957
  const [port, setPort] = useState('')
7795
7958
  const [busy, setBusy] = useState(false)
7796
7959
  const [creating, setCreating] = useState(false)
7960
+ const [editing, setEditing] = useState(null)
7797
7961
  const [fresh, setFresh] = useState(null) // { name, token } shown once
7798
7962
  useEffect(() => { if (st.value) setPort(String(st.value.port)) }, [st.value?.port])
7799
7963
  const s = st.value
@@ -7807,8 +7971,18 @@ window.__ModuleLoader__.load({
7807
7971
  if (!(await confirmDialog({ title: fmt(t, 'mcp.revokeConfirm', { name: tok.name }), message: t('mcp.revokeHint'), confirmLabel: t('mcp.revoke'), danger: true }))) return
7808
7972
  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
7973
  }
7974
+ const rotate = async (tok) => {
7975
+ if (!(await confirmDialog({ title: fmt(t, 'mcp.rotateConfirm', { name: tok.name }), message: t('mcp.rotateHint'), confirmLabel: t('mcp.rotate'), danger: true }))) return
7976
+ try { const r = await call('rotateMcpToken', { id: tok.id }); setFresh(r); notify(t('mcp.rotated'), 'ok'); st.reload() } catch (e) { notifyError(e) }
7977
+ }
7810
7978
  if (!s) return st.error ? h('div', { className: 'n2s-notice err' }, st.error.message) : h('div', { className: 'n2s-skel', style: { height: 200 } })
7811
7979
  const portDirty = port !== String(s.port)
7980
+ const toolNames = s.toolNames ?? []
7981
+ const limitsLabel = (tok) => [
7982
+ tok.expiresAt ? (tok.expired ? t('mcp.expired') : fmt(t, 'mcp.expiresOn', { date: new Date(tok.expiresAt).toLocaleDateString(uiLocale()) })) : null,
7983
+ Array.isArray(tok.tools) ? fmt(t, 'mcp.toolsN', { n: tok.tools.length }) : null,
7984
+ tok.rateLimit ? fmt(t, 'mcp.rateN', { n: tok.rateLimit }) : null,
7985
+ ].filter(Boolean)
7812
7986
  return h('div', { className: 'n2s-mcp' },
7813
7987
  h(SectionHead, { title: t('mcp.title'), desc: t('mcp.desc') }),
7814
7988
  h('div', { className: 'n2s-card n2s-mcp-server' },
@@ -7826,7 +8000,8 @@ window.__ModuleLoader__.load({
7826
8000
  h('span', { className: 'n2s-flabel' }, t('mcp.endpoint')),
7827
8001
  h('code', { className: 'n2s-mono', 'data-mcp-url': true }, s.url),
7828
8002
  h(McpCopy, { t, text: s.url })),
7829
- h('div', { className: 'n2s-faint n2s-small' }, t('mcp.localOnly'))),
8003
+ h(McpSelfTest, { t, disabled: !s.running }),
8004
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('mcp.localOnly'))),
7830
8005
  fresh && h('div', { className: 'n2s-card n2s-mcp-fresh', role: 'status' },
7831
8006
  h('div', { className: 'n2s-sechead-title' }, fmt(t, 'mcp.freshTitle', { name: fresh.name })),
7832
8007
  h('div', { className: 'n2s-notice warn', style: { margin: '8px 0' } }, t('mcp.freshHint')),
@@ -7836,17 +8011,25 @@ window.__ModuleLoader__.load({
7836
8011
  h('div', { className: 'n2s-row', style: { margin: '18px 0 8px' } },
7837
8012
  h('div', { className: 'n2s-sechead-title n2s-grow' }, t('mcp.tokens')),
7838
8013
  !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() } }),
8014
+ creating && h(McpTokenForm, { t, datasources, toolNames, onCancel: () => setCreating(false), onCreated: (tok) => { setCreating(false); setFresh(tok); st.reload() } }),
8015
+ editing && h(McpTokenEditor, { key: editing.id, t, tok: editing, toolNames, onCancel: () => setEditing(null), onSaved: () => { setEditing(null); notify(t('mcp.saved'), 'ok'); st.reload() } }),
7840
8016
  s.tokens.length === 0 && !creating
7841
8017
  ? h('div', { className: 'n2s-empty' }, h('h3', null, t('mcp.noTokens')), h('div', null, t('mcp.noTokensHint')))
7842
8018
  : 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)))),
8019
+ 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
8020
  h('tbody', null, s.tokens.map(tok => h('tr', { key: tok.id, 'data-token-id': tok.id },
7845
8021
  h('td', null, h('div', null, tok.name), h('div', { className: 'n2s-faint n2s-small n2s-mono' }, `${tok.prefix}…`)),
7846
8022
  h('td', null, h(McpScopeLabel, { t, scope: tok.scope })),
8023
+ 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
8024
  h('td', { className: 'n2s-small' }, when(tok.createdAt)),
7848
8025
  h('td', { className: 'n2s-small' }, when(tok.lastUsedAt)),
7849
- h('td', { style: { textAlign: 'right' } }, h('button', { type: 'button', className: 'n2s-btn small danger', onClick: () => revoke(tok) }, t('mcp.revoke')))))))),
8026
+ h('td', { className: 'n2s-mcp-actions' },
8027
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => setEditing(tok) }, t('mcp.edit')),
8028
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => rotate(tok) }, t('mcp.rotate')),
8029
+ h('button', { type: 'button', className: 'n2s-btn small danger', onClick: () => revoke(tok) }, t('mcp.revoke')))))))),
8030
+ s.tokens.length > 0 && h(React.Fragment, null,
8031
+ h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.callLog')),
8032
+ h(McpCallLog, { t, tokens: s.tokens })),
7850
8033
  h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.setup')),
7851
8034
  h('div', { className: 'n2s-card' }, h(McpSnippets, { t, url: s.url, token: null }),
7852
8035
  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.13.0",
3
+ "version": "0.14.0",
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": {
@@ -90,7 +90,7 @@
90
90
  "wecom"
91
91
  ],
92
92
  "engines": {
93
- "node": ">=24"
93
+ "node": ">=18"
94
94
  },
95
95
  "repository": {
96
96
  "type": "git",