@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 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 |
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@winstonsayno/mcp-gateway",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "A lightweight, open-source gateway for managing, routing, and monitoring multiple MCP (Model Context Protocol) servers.",
5
5
  "keywords": [
6
6
  "mcp",