@hedeqiang/dsh-nl2sql 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -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 +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}
|
|
@@ -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
|
|
7700
|
-
//
|
|
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
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
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'
|
|
7730
|
-
h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(where
|
|
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
|
-
|
|
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(
|
|
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', {
|
|
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.
|
|
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": {
|