@samanhappy/mcphub 1.0.36 → 1.0.38

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.
Files changed (102) hide show
  1. package/README.fr.md +47 -17
  2. package/README.md +44 -36
  3. package/README.zh.md +45 -35
  4. package/dist/constants/oauthServerDefaults.js +1 -0
  5. package/dist/constants/oauthServerDefaults.js.map +1 -1
  6. package/dist/controllers/groupController.js +63 -14
  7. package/dist/controllers/groupController.js.map +1 -1
  8. package/dist/controllers/oauthDynamicRegistrationController.js +6 -32
  9. package/dist/controllers/oauthDynamicRegistrationController.js.map +1 -1
  10. package/dist/controllers/serverController.js +46 -3
  11. package/dist/controllers/serverController.js.map +1 -1
  12. package/dist/dao/GroupDaoDbImpl.js +18 -0
  13. package/dist/dao/GroupDaoDbImpl.js.map +1 -1
  14. package/dist/dao/OAuthClientDaoDbImpl.js +3 -0
  15. package/dist/dao/OAuthClientDaoDbImpl.js.map +1 -1
  16. package/dist/db/connection.js +3 -2
  17. package/dist/db/connection.js.map +1 -1
  18. package/dist/db/entities/Group.js +9 -1
  19. package/dist/db/entities/Group.js.map +1 -1
  20. package/dist/db/entities/OAuthClient.js +4 -0
  21. package/dist/db/entities/OAuthClient.js.map +1 -1
  22. package/dist/db/groupSchema.js +29 -0
  23. package/dist/db/groupSchema.js.map +1 -0
  24. package/dist/models/OAuth.js +98 -1
  25. package/dist/models/OAuth.js.map +1 -1
  26. package/dist/routes/index.js +4 -3
  27. package/dist/routes/index.js.map +1 -1
  28. package/dist/services/groupService.js +57 -23
  29. package/dist/services/groupService.js.map +1 -1
  30. package/dist/services/logService.js +11 -2
  31. package/dist/services/logService.js.map +1 -1
  32. package/dist/services/mcpService.js +177 -17
  33. package/dist/services/mcpService.js.map +1 -1
  34. package/dist/services/oauthClientRegistration.js +22 -7
  35. package/dist/services/oauthClientRegistration.js.map +1 -1
  36. package/dist/services/oauthSettingsStore.js +3 -0
  37. package/dist/services/oauthSettingsStore.js.map +1 -1
  38. package/dist/services/sseService.js +63 -7
  39. package/dist/services/sseService.js.map +1 -1
  40. package/dist/utils/cacheUtils.js +133 -5
  41. package/dist/utils/cacheUtils.js.map +1 -1
  42. package/dist/utils/groupAccess.js +17 -0
  43. package/dist/utils/groupAccess.js.map +1 -0
  44. package/dist/utils/migration.js +3 -0
  45. package/dist/utils/migration.js.map +1 -1
  46. package/dist/utils/rateLimit.js +85 -18
  47. package/dist/utils/rateLimit.js.map +1 -1
  48. package/dist/utils/smartRouting.js +1 -1
  49. package/dist/utils/smartRouting.js.map +1 -1
  50. package/frontend/dist/assets/{ActivityPage-ZRx6b9hm.js → ActivityPage-BZNq-eGE.js} +2 -2
  51. package/frontend/dist/assets/{ActivityPage-ZRx6b9hm.js.map → ActivityPage-BZNq-eGE.js.map} +1 -1
  52. package/frontend/dist/assets/{CredentialsPage-C9vnh01H.js → CredentialsPage-7kyDJLsX.js} +2 -2
  53. package/frontend/dist/assets/{CredentialsPage-C9vnh01H.js.map → CredentialsPage-7kyDJLsX.js.map} +1 -1
  54. package/frontend/dist/assets/{Dashboard-BDGDQNz1.js → Dashboard-CElNx820.js} +2 -2
  55. package/frontend/dist/assets/{Dashboard-BDGDQNz1.js.map → Dashboard-CElNx820.js.map} +1 -1
  56. package/frontend/dist/assets/{EndpointCopy-BY51t0Ks.js → EndpointCopy-8jW5Xtka.js} +2 -2
  57. package/frontend/dist/assets/{EndpointCopy-BY51t0Ks.js.map → EndpointCopy-8jW5Xtka.js.map} +1 -1
  58. package/frontend/dist/assets/GroupsPage-dUkvX_iB.js +33 -0
  59. package/frontend/dist/assets/GroupsPage-dUkvX_iB.js.map +1 -0
  60. package/frontend/dist/assets/{LoginPage-CZOKlkR_.js → LoginPage-D23s7oM0.js} +2 -2
  61. package/frontend/dist/assets/{LoginPage-CZOKlkR_.js.map → LoginPage-D23s7oM0.js.map} +1 -1
  62. package/frontend/dist/assets/{LogsPage-FDWOOIPg.js → LogsPage-BuQMkmV-.js} +2 -2
  63. package/frontend/dist/assets/{LogsPage-FDWOOIPg.js.map → LogsPage-BuQMkmV-.js.map} +1 -1
  64. package/frontend/dist/assets/{MarketPage-DKfZgoIs.js → MarketPage-g9vEYzIq.js} +2 -2
  65. package/frontend/dist/assets/{MarketPage-DKfZgoIs.js.map → MarketPage-g9vEYzIq.js.map} +1 -1
  66. package/frontend/dist/assets/{OAuthConsentPage-Hk_DJ0dW.js → OAuthConsentPage-BiX1lpJR.js} +2 -2
  67. package/frontend/dist/assets/{OAuthConsentPage-Hk_DJ0dW.js.map → OAuthConsentPage-BiX1lpJR.js.map} +1 -1
  68. package/frontend/dist/assets/{PromptsPage-CT0C0_BI.js → PromptsPage-g8SRFY6s.js} +2 -2
  69. package/frontend/dist/assets/{PromptsPage-CT0C0_BI.js.map → PromptsPage-g8SRFY6s.js.map} +1 -1
  70. package/frontend/dist/assets/{ResourcesPage-CBgu-N5m.js → ResourcesPage-Ddhxi9Sf.js} +2 -2
  71. package/frontend/dist/assets/{ResourcesPage-CBgu-N5m.js.map → ResourcesPage-Ddhxi9Sf.js.map} +1 -1
  72. package/frontend/dist/assets/{ServersPage-CvcP8LFv.js → ServersPage-DAmKvrPV.js} +8 -8
  73. package/frontend/dist/assets/ServersPage-DAmKvrPV.js.map +1 -0
  74. package/frontend/dist/assets/SettingsPage-DZXjQZXS.js +12 -0
  75. package/frontend/dist/assets/SettingsPage-DZXjQZXS.js.map +1 -0
  76. package/frontend/dist/assets/{StatusDot-uIIW4IuH.js → StatusDot-CdzMqjTi.js} +2 -2
  77. package/frontend/dist/assets/{StatusDot-uIIW4IuH.js.map → StatusDot-CdzMqjTi.js.map} +1 -1
  78. package/frontend/dist/assets/{ToggleGroup-BToroeZy.js → ToggleGroup-CJd8X_31.js} +2 -2
  79. package/frontend/dist/assets/{ToggleGroup-BToroeZy.js.map → ToggleGroup-CJd8X_31.js.map} +1 -1
  80. package/frontend/dist/assets/{UsersPage-BjgJjf6L.js → UsersPage-CkP_x9Rt.js} +2 -2
  81. package/frontend/dist/assets/{UsersPage-BjgJjf6L.js.map → UsersPage-CkP_x9Rt.js.map} +1 -1
  82. package/frontend/dist/assets/index-Bu4XuAH4.js +3 -0
  83. package/frontend/dist/assets/index-Bu4XuAH4.js.map +1 -0
  84. package/frontend/dist/assets/{resourceService-sD9dxx3E.js → resourceService-w-cZ31FD.js} +2 -2
  85. package/frontend/dist/assets/{resourceService-sD9dxx3E.js.map → resourceService-w-cZ31FD.js.map} +1 -1
  86. package/frontend/dist/assets/{useCostData-QoPbsqyD.js → useCostData-C72NC0PQ.js} +2 -2
  87. package/frontend/dist/assets/{useCostData-QoPbsqyD.js.map → useCostData-C72NC0PQ.js.map} +1 -1
  88. package/frontend/dist/assets/useSettingsData-JTEMu74D.js +2 -0
  89. package/frontend/dist/assets/{useSettingsData-C4JrLqDh.js.map → useSettingsData-JTEMu74D.js.map} +1 -1
  90. package/frontend/dist/assets/{variableDetection-iFAhGAKs.js → variableDetection-DfhNyFy-.js} +2 -2
  91. package/frontend/dist/assets/{variableDetection-iFAhGAKs.js.map → variableDetection-DfhNyFy-.js.map} +1 -1
  92. package/frontend/dist/index.html +1 -1
  93. package/mcp_settings.json +2 -1
  94. package/package.json +1 -1
  95. package/frontend/dist/assets/GroupsPage-DyHyW7NW.js +0 -33
  96. package/frontend/dist/assets/GroupsPage-DyHyW7NW.js.map +0 -1
  97. package/frontend/dist/assets/ServersPage-CvcP8LFv.js.map +0 -1
  98. package/frontend/dist/assets/SettingsPage-DDSVt_xk.js +0 -12
  99. package/frontend/dist/assets/SettingsPage-DDSVt_xk.js.map +0 -1
  100. package/frontend/dist/assets/index-DjNOC0Ov.js +0 -3
  101. package/frontend/dist/assets/index-DjNOC0Ov.js.map +0 -1
  102. package/frontend/dist/assets/useSettingsData-C4JrLqDh.js +0 -2
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { canAccessGroupRoute } from '../utils/groupAccess.js';
2
3
  import { PrincipalRuntimeService } from './principalRuntimeService.js';
3
4
  import { UserContextService } from './userContextService.js';
4
5
  import { deleteCredentialBindings, missingCredentialError } from './credentialBindingService.js';
@@ -524,6 +525,82 @@ const normalizeResourceForCache = (resource) => {
524
525
  };
525
526
  // Store all server information
526
527
  let serverInfos = [];
528
+ /**
529
+ * How many upstream connections an initialization pass may open at once.
530
+ * Unset, empty or `0` keeps the previous behaviour of connecting every enabled
531
+ * server in parallel. Read per pass so a restart is enough to change it.
532
+ */
533
+ const resolveStartupConcurrency = () => {
534
+ const raw = process.env.STARTUP_CONNECT_CONCURRENCY?.trim();
535
+ if (!raw) {
536
+ return 0;
537
+ }
538
+ const parsed = Number(raw);
539
+ if (!Number.isInteger(parsed) || parsed < 0) {
540
+ logger.warn(`Ignoring invalid STARTUP_CONNECT_CONCURRENCY="${raw}"; expected a non-negative integer. Connecting without a limit.`);
541
+ return 0;
542
+ }
543
+ return parsed;
544
+ };
545
+ /**
546
+ * Rejection reason for a queued connection that was dropped before it started,
547
+ * because the server it belonged to was disabled, removed or superseded while
548
+ * it waited for a slot. Not a connection failure: nothing was spawned and there
549
+ * is nothing wrong with the server, so callers report it as a cancellation
550
+ * rather than marking the server as failed.
551
+ */
552
+ class QueuedConnectCancelledError extends Error {
553
+ constructor() {
554
+ super('Queued connection was cancelled before it started');
555
+ this.name = 'QueuedConnectCancelledError';
556
+ }
557
+ }
558
+ /**
559
+ * Defers the *invocation* of a connect rather than wrapping its promise, so a
560
+ * server waiting for a slot has not started its request yet and its timeout
561
+ * budget is not spent queueing. A limit of 0 runs everything immediately.
562
+ *
563
+ * Because work waits here, what the operator wants can change while it waits -
564
+ * a queued server may be disabled, removed or replaced by a later pass. The
565
+ * `isStillWanted` guard is therefore evaluated when the slot is granted, not
566
+ * when the work is queued; a queued entry that is no longer wanted is cancelled
567
+ * and its slot handed straight to the next in line.
568
+ */
569
+ const createConnectGate = (limit) => {
570
+ if (limit <= 0) {
571
+ // Nothing queues, so nothing can go stale while it waits.
572
+ return { run: (start) => start() };
573
+ }
574
+ let active = 0;
575
+ const waiting = [];
576
+ const startNextWaiting = () => {
577
+ waiting.shift()?.();
578
+ };
579
+ const release = () => {
580
+ active -= 1;
581
+ startNextWaiting();
582
+ };
583
+ return {
584
+ run: (start, isStillWanted) => new Promise((resolve, reject) => {
585
+ const begin = () => {
586
+ if (isStillWanted && !isStillWanted()) {
587
+ // The slot was never taken, so pass it on rather than release it.
588
+ reject(new QueuedConnectCancelledError());
589
+ startNextWaiting();
590
+ return;
591
+ }
592
+ active += 1;
593
+ start().then(resolve, reject).finally(release);
594
+ };
595
+ if (active < limit) {
596
+ begin();
597
+ }
598
+ else {
599
+ waiting.push(begin);
600
+ }
601
+ }),
602
+ };
603
+ };
527
604
  // Bumped by every initializeClientsFromSettings pass. Connections are started
528
605
  // fire-and-forget and settle long after the pass that started them returned, so
529
606
  // a callback needs to know whether a later pass has run before it writes its
@@ -1205,6 +1282,15 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1205
1282
  const existingServerInfos = serverInfos;
1206
1283
  const nextServerInfos = [];
1207
1284
  const generation = ++initGeneration;
1285
+ const concurrency = resolveStartupConcurrency();
1286
+ const connectGate = createConnectGate(concurrency);
1287
+ // Set once this pass swaps its entries into `serverInfos`. Until then a
1288
+ // queued server cannot have been retired by anyone, because the entry that
1289
+ // `closeServer`/`toggleServerStatus` would act on is not published yet.
1290
+ let entriesPublished = false;
1291
+ if (isInit && concurrency > 0) {
1292
+ logger.log(`Connecting upstream servers with a concurrency limit of ${concurrency}`);
1293
+ }
1208
1294
  try {
1209
1295
  for (const conf of allServers) {
1210
1296
  const { name } = conf;
@@ -1499,7 +1585,33 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1499
1585
  const current = serverInfos.find((si) => si.name === name);
1500
1586
  return current && current.client === client ? current : undefined;
1501
1587
  };
1502
- connectClientWithDiagnostics(client, transport, initRequestOptions || requestOptions)
1588
+ // A queued connection has not spawned anything yet, so cancelling it is
1589
+ // free - and necessary: `closeServerRuntime` clears the client/transport
1590
+ // references that would later own the child process, so connecting after
1591
+ // a disable strands a process nothing can clean up.
1592
+ const connectStillWanted = () => {
1593
+ // Until this pass publishes its entries, nothing else can have retired
1594
+ // this server: the entry a disable would act on does not exist yet.
1595
+ if (!entriesPublished)
1596
+ return true;
1597
+ const current = serverInfos.find((si) => si.name === name);
1598
+ // Gone from the configuration while it waited.
1599
+ if (!current)
1600
+ return false;
1601
+ // Client identity, not the generation, decides whether anyone is still
1602
+ // waiting on this connect - the same rule liveServerInfo() applies to
1603
+ // the result. A later pass that *preserved* this server spreads our
1604
+ // client into its entry and relies on this very connect to complete it
1605
+ // (a scoped reload preserves every other server whatever its status,
1606
+ // see #921), so cancelling on generation alone would strand it on
1607
+ // 'connecting' for good - the failure #1150 was about. A later pass
1608
+ // that rebuilt the server carries a client of its own, and disabling
1609
+ // closes the runtime before replacing the entry, so in both of those
1610
+ // cases the published entry no longer carries our client.
1611
+ return current.client === client && current.enabled !== false;
1612
+ };
1613
+ connectGate
1614
+ .run(() => connectClientWithDiagnostics(client, transport, initRequestOptions || requestOptions), connectStillWanted)
1503
1615
  .then(() => {
1504
1616
  const info = liveServerInfo();
1505
1617
  if (!info) {
@@ -1592,6 +1704,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1592
1704
  }
1593
1705
  })
1594
1706
  .catch(async (error) => {
1707
+ if (error instanceof QueuedConnectCancelledError) {
1708
+ logger.log(`Cancelled queued connection for server: ${name} (disabled, removed or superseded while queued)`);
1709
+ closeServerRuntime(serverInfo);
1710
+ return;
1711
+ }
1595
1712
  const info = liveServerInfo();
1596
1713
  if (!info) {
1597
1714
  logger.log(`Discarding superseded connection failure for server: ${name}`);
@@ -1631,10 +1748,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1631
1748
  throw error;
1632
1749
  }
1633
1750
  serverInfos = nextServerInfos;
1751
+ entriesPublished = true;
1634
1752
  // Populate the tool cache for on-demand stdio servers so their tools are
1635
1753
  // visible to agents, then put them back to sleep. Running here (rather than
1636
1754
  // only at startup) also covers servers added/enabled/reloaded after startup.
1637
- const primePromise = primeOnDemandServers();
1755
+ const primePromise = primeOnDemandServers(connectGate);
1638
1756
  // At full startup (no serverName) keep prime fire-and-forget so a slow or
1639
1757
  // broken on-demand server does not block init. For a targeted reload/edit
1640
1758
  // (serverName set), await it so the caller - and the dashboard refresh that
@@ -1734,9 +1852,14 @@ export const getServersInfo = async (page, limit, user) => {
1734
1852
  const enabled = serverConfig ? serverConfig.enabled !== false : true;
1735
1853
  const resolvedType = inferServerType(serverConfig);
1736
1854
  const oauthConnected = Boolean(serverConfig?.oauth?.accessToken || serverConfig?.oauth?.refreshToken);
1737
- // Add enabled status and custom description to each tool
1855
+ // Add enabled status and custom description to each tool.
1856
+ // toggleTool stores under whatever key the caller passed (prefixed,
1857
+ // as the dashboard sends, or bare, as a direct API call may) with no
1858
+ // normalization — check both so the dashboard reflects a disable set
1859
+ // either way instead of always showing enabled:true for one of them.
1738
1860
  const toolsWithEnabled = tools.map((tool) => {
1739
- const toolConfig = serverConfig?.tools?.[tool.name];
1861
+ const bareToolName = normalizeToolNameForServer(name, tool.name);
1862
+ const toolConfig = serverConfig?.tools?.[tool.name] ?? serverConfig?.tools?.[bareToolName];
1740
1863
  return buildToolWithDescriptionMetadata(tool, toolConfig);
1741
1864
  });
1742
1865
  const promptsWithEnabled = prompts.map((prompt) => {
@@ -1871,7 +1994,9 @@ export const reinstallServer = async (serverName) => {
1871
1994
  try {
1872
1995
  // For npx, clear cache directory synchronously before reconnect.
1873
1996
  // For uvx, this is a no-op — refresh is handled via --refresh flag injection.
1874
- await clearRunnerCache(command);
1997
+ // Pass the server's own arguments so the npx clear can be scoped to this
1998
+ // server's package instead of discarding every npx server's install.
1999
+ await clearRunnerCache(command, serverConfig.args || []);
1875
2000
  // Close and reconnect (will pick up pendingReinstalls flag for uvx)
1876
2001
  await reconnectServer(serverName);
1877
2002
  logger.log(`Successfully initiated reinstall for server: ${serverName}`);
@@ -1890,7 +2015,11 @@ const filterToolsByConfig = async (serverName, tools) => {
1890
2015
  return tools;
1891
2016
  }
1892
2017
  return tools.filter((tool) => {
1893
- const toolConfig = serverConfig.tools?.[tool.name];
2018
+ // toggleTool stores the disable under whatever key string the caller used
2019
+ // (bare upstream name or server-prefixed name, never normalized) — check
2020
+ // both, same as findToolOnServer's execution-time gate.
2021
+ const bareToolName = normalizeToolNameForServer(serverName, tool.name);
2022
+ const toolConfig = serverConfig.tools?.[tool.name] ?? serverConfig.tools?.[bareToolName];
1894
2023
  // If tool is not in config, it's enabled by default
1895
2024
  return toolConfig?.enabled !== false;
1896
2025
  });
@@ -2251,7 +2380,7 @@ const isStdioServer = (conf) => !!conf && (conf.type === 'stdio' || (!conf.type
2251
2380
  * does not block init. Each server is isolated so one failure does not affect
2252
2381
  * the others. See #1029 / #1032.
2253
2382
  */
2254
- const primeOnDemandServers = () => {
2383
+ const primeOnDemandServers = (gate) => {
2255
2384
  const targets = serverInfos.filter((si) => si.config?.startOnDemand === true &&
2256
2385
  !hasCredentialTemplate(si.config) &&
2257
2386
  isStdioServer(si.config) &&
@@ -2260,9 +2389,13 @@ const primeOnDemandServers = () => {
2260
2389
  if (targets.length === 0)
2261
2390
  return Promise.resolve();
2262
2391
  logger.log(`Priming ${targets.length} on-demand server(s) for tool discovery…`);
2392
+ // Priming happens right after init, so it competes for the same machine.
2393
+ // Share the pass's concurrency limit rather than waking every sleeping server
2394
+ // at once and undoing the pacing above.
2395
+ const run = gate ? gate.run : (start) => start();
2263
2396
  return Promise.allSettled(targets.map(async (si) => {
2264
2397
  try {
2265
- await ensureServerReady(si);
2398
+ await run(() => ensureServerReady(si));
2266
2399
  // Sleep the child unless a tool call is using it; keep the cached tool list.
2267
2400
  shutdownOnDemandServer(si);
2268
2401
  logger.log('On-demand server tool cache primed');
@@ -2482,8 +2615,36 @@ const resolveNameFromGroup = (name, serverName, serverConfig) => {
2482
2615
  return replacePrefixedServerName(name, getExposedServerName(serverName, serverConfig), serverName);
2483
2616
  };
2484
2617
  const findToolOnServer = (serverInfo, toolName, allowRawName) => {
2485
- return serverInfo.tools.find((tool) => tool.name === toolName ||
2618
+ const tool = serverInfo.tools.find((tool) => tool.name === toolName ||
2486
2619
  (allowRawName && normalizeToolNameForServer(serverInfo.name, tool.name) === toolName));
2620
+ if (!tool) {
2621
+ return undefined;
2622
+ }
2623
+ // A tool disabled via POST /api/servers/:server/tools/:tool/toggle must be
2624
+ // unresolvable for execution, not just hidden from tools/list — otherwise a
2625
+ // caller who already knows the tool name can invoke it regardless of the
2626
+ // disabled flag.
2627
+ //
2628
+ // The runtime tool cache (serverInfo.tools, built by normalizeToolForCache)
2629
+ // never carries an `enabled` field — that field only exists on the
2630
+ // *projection* built for the dashboard/API (buildToolWithDescriptionMetadata),
2631
+ // which is a different object. So the persisted per-tool config must be
2632
+ // consulted directly here.
2633
+ //
2634
+ // toggleTool (serverController.ts) stores `tools[toolName] = ...` using
2635
+ // whatever string the caller passed as :toolName, with no normalization.
2636
+ // The dashboard sends the already-prefixed `tool.name`, matching what
2637
+ // getServersInfo's toolsWithEnabled already reads — but any other caller
2638
+ // (REST API used directly, scripts, docs examples) can just as validly
2639
+ // pass the bare upstream name, which then never matches a prefixed-only
2640
+ // lookup. Check both so a disable takes effect regardless of which form
2641
+ // was used to set it.
2642
+ const bareToolName = normalizeToolNameForServer(serverInfo.name, tool.name);
2643
+ const toolConfig = serverInfo.config?.tools?.[tool.name] ?? serverInfo.config?.tools?.[bareToolName];
2644
+ if (toolConfig && toolConfig.enabled === false) {
2645
+ return undefined;
2646
+ }
2647
+ return tool;
2487
2648
  };
2488
2649
  const assertToolAvailableForRoute = (tool, appsRouteContext) => {
2489
2650
  if (isAppOnlyTool(tool) && !appsRouteContext.enabled) {
@@ -3593,13 +3754,8 @@ export const createMcpServer = (name, version, options) => {
3593
3754
  return server;
3594
3755
  };
3595
3756
  export const getFilteredServerInfosForGroup = async (group, options) => {
3596
- // Resolve group server configs. We look up the group directly from the DAO
3597
- // rather than going through getServerConfigsInGroup (which calls getAllGroups
3598
- // and applies filterData on groups). Groups don't carry a visibility field,
3599
- // so admin-owned groups would be filtered out for non-admin users even though
3600
- // bearer-key auth already authorized access and individual servers inside the
3601
- // group may be public. Server-level filterData below still enforces per-server
3602
- // visibility. Fix for #914.
3757
+ // Resolve raw groups so legacy routing remains compatible, then enforce explicit visibility.
3758
+ let matchedGroup = false;
3603
3759
  let serverConfigs = [];
3604
3760
  if (group) {
3605
3761
  const groupDao = getGroupDao();
@@ -3611,6 +3767,10 @@ export const getFilteredServerInfosForGroup = async (group, options) => {
3611
3767
  }
3612
3768
  }
3613
3769
  if (foundGroup) {
3770
+ matchedGroup = true;
3771
+ if (!canAccessGroupRoute(foundGroup)) {
3772
+ return { filteredServerInfos: [], serverConfigsByName: new Map() };
3773
+ }
3614
3774
  serverConfigs = normalizeGroupServers(foundGroup.servers || []);
3615
3775
  }
3616
3776
  }
@@ -3626,7 +3786,7 @@ export const getFilteredServerInfosForGroup = async (group, options) => {
3626
3786
  filteredServerInfos.push(serverInfo);
3627
3787
  continue;
3628
3788
  }
3629
- if (serverNamesInGroup.size === 0) {
3789
+ if (!matchedGroup) {
3630
3790
  if (serverInfo.name === group) {
3631
3791
  filteredServerInfos.push(serverInfo);
3632
3792
  }