@winstonsayno/mcp-gateway 4.5.0 → 4.6.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 +11 -0
- package/README.md +8 -0
- package/dashboard/index.html +104 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [4.6.0] - 2026-10-08
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Graphical config editor** in the dashboard (new **Config** tab): edit general settings (log level, tool naming,
|
|
16
|
+
rate limit) and the server list (add / remove, transport, URL or command, timeout, enabled) in forms, or the full
|
|
17
|
+
schema-v4 JSON; **Validate**, **Preview changes** (diff) and **Apply** (hot reload, with confirmation) use the admin
|
|
18
|
+
API (`/api/v1/admin/config…`). Secrets stay `<redacted>` and keep their running values.
|
|
19
|
+
- The tab is read-only (Apply disabled, with a hint) unless `admin.configApi: true`; operators only.
|
|
20
|
+
- Bilingual (English / 中文), no inline handlers — the dashboard CSP (hashed inline script) is unchanged.
|
|
21
|
+
- The GitHub Pages demo has a simulated config backend (validate / diff / dry run / apply).
|
|
22
|
+
|
|
12
23
|
## [4.5.0] - 2026-10-08
|
|
13
24
|
|
|
14
25
|
### Added
|
package/README.md
CHANGED
|
@@ -583,6 +583,14 @@ await gateway.start();
|
|
|
583
583
|
process.on('SIGTERM', () => gateway.stop());
|
|
584
584
|
```
|
|
585
585
|
|
|
586
|
+
## What's New in v4.6
|
|
587
|
+
|
|
588
|
+
| Feature | Description |
|
|
589
|
+
|---------|-------------|
|
|
590
|
+
| **Config editor** | New dashboard tab: forms + raw JSON for the running config |
|
|
591
|
+
| **Validate · diff · apply** | Schema-checked, previewed, hot-reloaded — no restart, no YAML editing |
|
|
592
|
+
| **Safe by default** | Read-only unless `admin.configApi: true`; secrets stay redacted |
|
|
593
|
+
|
|
586
594
|
## What's New in v4.5
|
|
587
595
|
|
|
588
596
|
| Feature | Description |
|
package/dashboard/index.html
CHANGED
|
@@ -519,6 +519,19 @@ tbody tr:hover { background: var(--surface-2); }
|
|
|
519
519
|
</div>
|
|
520
520
|
</section>
|
|
521
521
|
|
|
522
|
+
<!-- Config editor (4.6) -->
|
|
523
|
+
<section class="view" id="view-config" role="tabpanel" aria-labelledby="tab-config">
|
|
524
|
+
<div class="page-head"><div><h1 data-i18n="cfTitle">Configuration</h1><p data-i18n="cfSub">Edit the running config</p></div>
|
|
525
|
+
<div class="row"><button class="btn" id="cfValidate" type="button" data-i18n="cfValidate">Validate</button><button class="btn" id="cfDiff" type="button" data-i18n="cfDiff">Preview changes</button><button class="btn primary" id="cfApply" type="button" data-i18n="cfApply">Apply</button></div></div>
|
|
526
|
+
<p class="badge" id="cfLocked" hidden data-i18n="cfLocked">Read-only</p>
|
|
527
|
+
<div class="card" id="cfResult" hidden aria-live="polite"></div>
|
|
528
|
+
<div class="card" style="margin-top:14px"><h2 data-i18n="cfGeneral">General</h2><div class="filters" id="cfGeneral"></div></div>
|
|
529
|
+
<div class="card" style="margin-top:14px"><h2 data-i18n="cfServers">Servers</h2><div class="table-wrap"><table><thead><tr><th>id</th><th>transport</th><th>url / command</th><th>timeout</th><th data-i18n="cfEnabled">Enabled</th><th></th></tr></thead><tbody id="cfServers"></tbody></table></div>
|
|
530
|
+
<div class="row" style="margin-top:10px"><button class="btn sm" id="cfAdd" type="button" data-i18n="cfAddServer">Add server</button></div></div>
|
|
531
|
+
<div class="card" style="margin-top:14px"><h2 data-i18n="cfRaw">Raw JSON</h2><textarea class="input mono" id="cfJson" rows="16" spellcheck="false" style="width:100%;resize:vertical"></textarea>
|
|
532
|
+
<div class="row" style="margin-top:10px"><button class="btn sm" id="cfUseJson" type="button" data-i18n="cfUseJson">Use JSON</button></div></div>
|
|
533
|
+
</section>
|
|
534
|
+
|
|
522
535
|
<!-- Connect -->
|
|
523
536
|
<section class="view" id="view-connect" role="tabpanel" aria-labelledby="tab-connect">
|
|
524
537
|
<div class="page-head"><div><h1 data-i18n="cnTitle">Connect a client</h1><p data-i18n="cnSub">Point any MCP client at this gateway</p></div></div>
|
|
@@ -611,7 +624,8 @@ const I18N = {
|
|
|
611
624
|
en: {
|
|
612
625
|
skip: 'Skip to content', guide: 'Guide', apiKey: 'API key', theme: 'Toggle theme', keyPh: 'API key or JWT',
|
|
613
626
|
remember: 'Remember on this device', saveConnect: 'Save & connect', clear: 'Clear', close: 'Close',
|
|
614
|
-
tabOverview: 'Overview', tabServers: 'Servers', tabPlayground: 'Playground', tabHistory: 'History', tabConnect: 'Connect',
|
|
627
|
+
tabOverview: 'Overview', tabServers: 'Servers', tabPlayground: 'Playground', tabHistory: 'History', tabConfig: 'Config', tabConnect: 'Connect',
|
|
628
|
+
cfTitle: 'Configuration', cfSub: 'Edit the running config — validated, diffed and hot-reloaded', cfGeneral: 'General', cfServers: 'Servers', cfRaw: 'Raw JSON (schema v4)', cfValidate: 'Validate', cfDiff: 'Preview changes', cfApply: 'Apply', cfAddServer: 'Add server', cfRemove: 'Remove', cfValid: 'Configuration is valid', cfNoChanges: 'No changes', cfApplied: 'Applied — hot reloaded', cfLocked: 'Read-only: set admin.configApi: true to apply changes from the dashboard', cfBadJson: 'Invalid JSON', cfUseJson: 'Use JSON', cfConfirm: 'Apply these changes to the running gateway?', cfRate: 'Rate limit (requests / window s)', cfNaming: 'Tool naming', cfLog: 'Log level', cfEnabled: 'Enabled',
|
|
615
629
|
ovTitle: 'Overview', ovSub: 'Live traffic through the gateway', window: 'Time window',
|
|
616
630
|
reqRate: 'Request rate', requests: 'Requests', errors: 'Errors', latency: 'Latency', topTools: 'Top tools', perKey: 'Usage per key', perServer: 'Calls per server', approvals: 'Pending approvals', tenants: 'Workspaces', rpTitle: 'Request details', replay: 'Replay', replayOf: 'replay of', rpArgs: 'Arguments', rpResult: 'Result', rpReplayed: 'Replay', rpSame: 'identical', rpDifferent: 'different', rpBadJson: 'Arguments must be valid JSON', rpHint: 'Edit the arguments and replay through the gateway', rpTruncated: 'Payload too large to capture; pass arguments to replay', rpOff: 'Not captured. Turn on replay.enabled to inspect and replay calls.', quotas: 'Quotas', lb: 'Load balancing', cache: 'Result cache', purge: 'Purge', purged: 'Purged {n} cache entries', rules: 'rules', noUsage: 'No usage in the current period', ejected: 'ejected', entries: 'Entries', hitRate: 'Hit rate', hits: 'Hits', misses: 'Misses', deduped: 'Deduplicated', evictions: 'Evictions', catalog: 'Add a server', add: 'Add', added: 'Server added', installOff: 'read-only (catalog.install is off)', installedAs: 'installed as', role: 'Role', members: 'members', roleSaved: 'Role updated', approve: 'Approve', deny: 'Deny', approved: 'Approved', denied: 'Denied', expires: 'expires',
|
|
617
631
|
errRate: 'Error rate', liveStream: 'Live requests', pause: 'Pause', health: 'Server health',
|
|
@@ -656,7 +670,8 @@ const I18N = {
|
|
|
656
670
|
zh: {
|
|
657
671
|
skip: '跳到内容', guide: '新手引导', apiKey: 'API 密钥', theme: '切换主题', keyPh: 'API 密钥或 JWT',
|
|
658
672
|
remember: '在此设备上记住', saveConnect: '保存并连接', clear: '清除', close: '关闭',
|
|
659
|
-
tabOverview: '总览', tabServers: '服务器', tabPlayground: '调试台', tabHistory: '历史', tabConnect: '接入',
|
|
673
|
+
tabOverview: '总览', tabServers: '服务器', tabPlayground: '调试台', tabHistory: '历史', tabConfig: '配置', tabConnect: '接入',
|
|
674
|
+
cfTitle: '配置', cfSub: '编辑运行中的配置——校验、对比差异并热加载', cfGeneral: '常规', cfServers: '服务器', cfRaw: '原始 JSON(schema v4)', cfValidate: '校验', cfDiff: '预览变更', cfApply: '应用', cfAddServer: '添加服务器', cfRemove: '移除', cfValid: '配置有效', cfNoChanges: '没有变更', cfApplied: '已应用(热加载)', cfLocked: '只读:设置 admin.configApi: true 后才能在仪表盘中应用变更', cfBadJson: 'JSON 无效', cfUseJson: '使用 JSON', cfConfirm: '将这些变更应用到运行中的网关?', cfRate: '限流(请求数 / 窗口秒)', cfNaming: '工具命名', cfLog: '日志级别', cfEnabled: '启用',
|
|
660
675
|
ovTitle: '总览', ovSub: '网关实时流量', window: '时间范围',
|
|
661
676
|
reqRate: '请求速率', requests: '请求', errors: '错误', latency: '延迟', topTools: '热门工具', perKey: '各密钥用量', perServer: '各服务器调用', approvals: '待审批调用', tenants: '工作区', rpTitle: '请求详情', replay: '重放', replayOf: '重放自', rpArgs: '参数', rpResult: '结果', rpReplayed: '重放结果', rpSame: '一致', rpDifferent: '不一致', rpBadJson: '参数必须是合法 JSON', rpHint: '可修改参数后经网关重放', rpTruncated: '负载过大未被捕获;重放需手动填写参数', rpOff: '未捕获。开启 replay.enabled 后可查看并重放调用。', quotas: '配额', lb: '负载均衡', cache: '结果缓存', purge: '清空', purged: '已清除 {n} 条缓存', rules: '条规则', noUsage: '当前周期暂无用量', ejected: '已摘除', entries: '条目', hitRate: '命中率', hits: '命中', misses: '未命中', deduped: '合并请求', evictions: '淘汰', catalog: '添加服务器', add: '添加', added: '已添加服务器', installOff: '只读(未开启 catalog.install)', installedAs: '已安装为', role: '角色', members: '成员', roleSaved: '角色已更新', approve: '批准', deny: '拒绝', approved: '已批准', denied: '已拒绝', expires: '到期',
|
|
662
677
|
errRate: '错误率', liveStream: '实时请求', pause: '暂停', health: '服务器健康',
|
|
@@ -736,6 +751,7 @@ const ICON = {
|
|
|
736
751
|
servers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/></svg>',
|
|
737
752
|
playground: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M7 4v16l13-8z"/></svg>',
|
|
738
753
|
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/></svg>',
|
|
754
|
+
config: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg>',
|
|
739
755
|
connect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 15l6-6M10.5 6.5l1.8-1.8a4 4 0 0 1 5.7 5.7l-1.8 1.8M13.5 17.5l-1.8 1.8a4 4 0 0 1-5.7-5.7l1.8-1.8"/></svg>',
|
|
740
756
|
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',
|
|
741
757
|
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-10"/></svg>',
|
|
@@ -836,7 +852,7 @@ const state = {
|
|
|
836
852
|
};
|
|
837
853
|
|
|
838
854
|
// ─── Navigation ──────────────────────────────────────────────────────────────
|
|
839
|
-
const VIEWS = ['overview', 'servers', 'playground', 'history', 'connect'];
|
|
855
|
+
const VIEWS = ['overview', 'servers', 'playground', 'history', 'config', 'connect'];
|
|
840
856
|
function buildNav() {
|
|
841
857
|
const tabs = $('#tabs'), bottom = $('#bottomNav');
|
|
842
858
|
tabs.querySelectorAll('.tab').forEach((b) => b.remove());
|
|
@@ -886,12 +902,97 @@ function go(view, push = true) {
|
|
|
886
902
|
if (view === 'servers') loadServers();
|
|
887
903
|
if (view === 'playground') renderPlayground($('#pgMain'), 'main');
|
|
888
904
|
if (view === 'history') loadHistory();
|
|
905
|
+
if (view === 'config') loadConfigEditor();
|
|
889
906
|
if (view === 'connect') { renderSnippets($('#connectCard'), 'main'); loadSecurity(); }
|
|
890
907
|
if (view === 'overview') requestAnimationFrame(renderCharts);
|
|
891
908
|
}
|
|
892
909
|
window.addEventListener('hashchange', () => go(location.hash.slice(1), false));
|
|
893
910
|
window.addEventListener('resize', () => { markNav(); scheduleCharts(); });
|
|
894
911
|
|
|
912
|
+
// ─── Config editor (4.6) ─────────────────────────────────────────────────────
|
|
913
|
+
const cf = { cfg: null, locked: false };
|
|
914
|
+
async function loadConfigEditor() {
|
|
915
|
+
try {
|
|
916
|
+
const r = await api('/admin/config');
|
|
917
|
+
cf.cfg = r.config;
|
|
918
|
+
try { await api('/admin/config?dryRun=true', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(cf.cfg) }); cf.locked = false; } catch (e) { cf.locked = e.status === 403; }
|
|
919
|
+
renderConfigEditor();
|
|
920
|
+
} catch (e) { cfShow([esc(e.message)], true); }
|
|
921
|
+
}
|
|
922
|
+
function cfShow(lines, bad) {
|
|
923
|
+
const box = $('#cfResult');
|
|
924
|
+
box.hidden = false;
|
|
925
|
+
box.innerHTML = `<div class="badge" style="color:var(--${bad ? 'bad' : 'good'})">${bad ? '✕' : '✓'}</div><ul class="sec-list">${lines.map((l) => `<li>${l}</li>`).join('')}</ul>`;
|
|
926
|
+
}
|
|
927
|
+
function cfField(id, label, html) { return `<div class="field"><label for="${id}">${esc(label)}</label>${html}</div>`; }
|
|
928
|
+
function renderConfigEditor() {
|
|
929
|
+
const c = cf.cfg || {};
|
|
930
|
+
$('#cfLocked').hidden = !cf.locked;
|
|
931
|
+
$('#cfApply').disabled = cf.locked;
|
|
932
|
+
const opt = (vals, cur) => vals.map((v) => `<option${v === cur ? ' selected' : ''}>${esc(v)}</option>`).join('');
|
|
933
|
+
$('#cfGeneral').innerHTML =
|
|
934
|
+
cfField('cf-log', t('cfLog'), `<select class="input" id="cf-log" data-cf="logLevel">${opt(['debug', 'info', 'warn', 'error'], c.logLevel || 'info')}</select>`) +
|
|
935
|
+
cfField('cf-naming', t('cfNaming'), `<select class="input" id="cf-naming" data-cf="mcp.toolNaming">${opt(['auto', 'prefix'], (c.mcp && c.mcp.toolNaming) || 'auto')}</select>`) +
|
|
936
|
+
cfField('cf-rl', t('cfRate'), `<div class="row"><input class="input" id="cf-rl" type="number" min="1" data-cf="rateLimit.limit" data-num value="${esc(String((c.rateLimit && c.rateLimit.limit) ?? ''))}"><input class="input" type="number" min="1" aria-label="window seconds" data-cf="rateLimit.windowSeconds" data-num value="${esc(String((c.rateLimit && c.rateLimit.windowSeconds) ?? ''))}"></div>`);
|
|
937
|
+
const servers = c.servers || [];
|
|
938
|
+
$('#cfServers').innerHTML = servers.map((s, i) => `<tr data-i="${i}">
|
|
939
|
+
<td><input class="input mono" aria-label="id" data-srv="id" value="${esc(s.id || '')}"></td>
|
|
940
|
+
<td><select class="input" aria-label="transport" data-srv="transport">${opt(['stdio', 'streamable-http', 'sse', 'websocket'], s.transport || 'stdio')}</select></td>
|
|
941
|
+
<td><input class="input mono" aria-label="url or command" data-srv="${s.transport === 'stdio' ? 'command' : 'url'}" value="${esc(s.transport === 'stdio' ? s.command || '' : s.url || '')}"></td>
|
|
942
|
+
<td><input class="input" type="number" min="1" aria-label="timeout" data-srv="timeout" data-num value="${esc(String(s.timeout ?? ''))}"></td>
|
|
943
|
+
<td><input type="checkbox" aria-label="${esc(t('cfEnabled'))}" data-srv="enabled" ${s.enabled === false ? '' : 'checked'}></td>
|
|
944
|
+
<td><button class="btn ghost sm" type="button" data-cf-remove="${i}">${esc(t('cfRemove'))}</button></td></tr>`).join('');
|
|
945
|
+
$('#cfJson').value = JSON.stringify(c, null, 2);
|
|
946
|
+
}
|
|
947
|
+
function cfSet(obj, path, value) {
|
|
948
|
+
const parts = path.split('.');
|
|
949
|
+
let o = obj;
|
|
950
|
+
for (const p of parts.slice(0, -1)) o = o[p] = o[p] && typeof o[p] === 'object' ? o[p] : {};
|
|
951
|
+
if (value === undefined) delete o[parts.at(-1)]; else o[parts.at(-1)] = value;
|
|
952
|
+
}
|
|
953
|
+
function cfValue(el) {
|
|
954
|
+
if (el.type === 'checkbox') return el.checked;
|
|
955
|
+
if (el.hasAttribute('data-num')) return el.value === '' ? undefined : Number(el.value);
|
|
956
|
+
return el.value;
|
|
957
|
+
}
|
|
958
|
+
document.addEventListener('change', (e) => {
|
|
959
|
+
const el = e.target;
|
|
960
|
+
if (!cf.cfg || !(el instanceof HTMLElement) || !el.closest('#view-config')) return;
|
|
961
|
+
if (el.dataset.cf) cfSet(cf.cfg, el.dataset.cf, cfValue(el));
|
|
962
|
+
const row = el.closest('tr[data-i]');
|
|
963
|
+
if (row && el.dataset.srv) {
|
|
964
|
+
const s = cf.cfg.servers[Number(row.dataset.i)];
|
|
965
|
+
const v = cfValue(el);
|
|
966
|
+
if (el.dataset.srv === 'enabled') { if (v) delete s.enabled; else s.enabled = false; } else if (v === undefined || v === '') delete s[el.dataset.srv]; else s[el.dataset.srv] = v;
|
|
967
|
+
if (el.dataset.srv === 'transport') { renderConfigEditor(); return; }
|
|
968
|
+
}
|
|
969
|
+
if (el.id !== 'cfJson') $('#cfJson').value = JSON.stringify(cf.cfg, null, 2);
|
|
970
|
+
});
|
|
971
|
+
document.addEventListener('click', async (e) => {
|
|
972
|
+
const b = e.target instanceof Element ? e.target.closest('button') : null;
|
|
973
|
+
if (!b || !b.closest('#view-config') || !cf.cfg) return;
|
|
974
|
+
if (b.dataset.cfRemove !== undefined) { cf.cfg.servers.splice(Number(b.dataset.cfRemove), 1); renderConfigEditor(); return; }
|
|
975
|
+
if (b.id === 'cfAdd') { (cf.cfg.servers = cf.cfg.servers || []).push({ id: `server-${cf.cfg.servers.length + 1}`, name: 'New server', transport: 'streamable-http', url: 'https://' }); renderConfigEditor(); return; }
|
|
976
|
+
if (b.id === 'cfUseJson') { try { cf.cfg = JSON.parse($('#cfJson').value); renderConfigEditor(); } catch { cfShow([esc(t('cfBadJson'))], true); } return; }
|
|
977
|
+
const json = { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(cf.cfg) };
|
|
978
|
+
try {
|
|
979
|
+
if (b.id === 'cfValidate') {
|
|
980
|
+
const r = await api('/admin/config/validate', json);
|
|
981
|
+
return cfShow(r.valid ? [esc(t('cfValid'))] : r.errors.map(esc), !r.valid);
|
|
982
|
+
}
|
|
983
|
+
const r = await api('/admin/config/diff', json);
|
|
984
|
+
const lines = (r.changes || []).map((c) => `<code>${esc(c.change)}</code> ${esc(c.path)}`);
|
|
985
|
+
if (b.id === 'cfDiff') return cfShow(lines.length ? lines : [esc(t('cfNoChanges'))], false);
|
|
986
|
+
if (b.id === 'cfApply') {
|
|
987
|
+
if (!lines.length) return cfShow([esc(t('cfNoChanges'))], false);
|
|
988
|
+
if (!confirm(t('cfConfirm'))) return;
|
|
989
|
+
const done = await api('/admin/config', { ...json, method: 'PUT' });
|
|
990
|
+
cfShow([esc(t(done.applied ? 'cfApplied' : 'cfNoChanges')), ...lines], false);
|
|
991
|
+
loadConfigEditor();
|
|
992
|
+
}
|
|
993
|
+
} catch (err) { cfShow([esc(err.message), ...((err.body && err.body.errors) || []).map(esc)], true); }
|
|
994
|
+
});
|
|
995
|
+
|
|
895
996
|
// ─── Live connection (SSE over fetch → fallback polling) ─────────────────────
|
|
896
997
|
let sseAbort = null, pollTimer = null, sseRetry = 0, statsTimer = null, statsPending = false;
|
|
897
998
|
function updateLive() {
|
package/package.json
CHANGED