@samanhappy/mcphub 1.0.31 → 1.0.33

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 (311) hide show
  1. package/README.md +11 -1
  2. package/README.zh.md +12 -2
  3. package/dist/betterAuth.js +13 -5
  4. package/dist/betterAuth.js.map +1 -1
  5. package/dist/cli/commands/cache.js.map +1 -1
  6. package/dist/cli/commands/call.js +2 -2
  7. package/dist/cli/commands/call.js.map +1 -1
  8. package/dist/cli/commands/config.js.map +1 -1
  9. package/dist/cli/commands/discover.js.map +1 -1
  10. package/dist/cli/commands/export.js.map +1 -1
  11. package/dist/cli/commands/groups.js.map +1 -1
  12. package/dist/cli/commands/install.js +1 -1
  13. package/dist/cli/commands/install.js.map +1 -1
  14. package/dist/cli/commands/keys.js.map +1 -1
  15. package/dist/cli/commands/login.js.map +1 -1
  16. package/dist/cli/commands/servers.js.map +1 -1
  17. package/dist/cli/commands/tools.js +2 -2
  18. package/dist/cli/commands/tools.js.map +1 -1
  19. package/dist/cli/context.js +1 -1
  20. package/dist/cli/context.js.map +1 -1
  21. package/dist/cli/http.js +3 -1
  22. package/dist/cli/http.js.map +1 -1
  23. package/dist/clients/openapi.js +498 -76
  24. package/dist/clients/openapi.js.map +1 -1
  25. package/dist/config/DaoConfigService.js +3 -2
  26. package/dist/config/DaoConfigService.js.map +1 -1
  27. package/dist/config/configManager.js +9 -8
  28. package/dist/config/configManager.js.map +1 -1
  29. package/dist/config/index.js +54 -8
  30. package/dist/config/index.js.map +1 -1
  31. package/dist/config/jwt.js +2 -1
  32. package/dist/config/jwt.js.map +1 -1
  33. package/dist/config/migrationUtils.js +46 -45
  34. package/dist/config/migrationUtils.js.map +1 -1
  35. package/dist/constants/oauthServerDefaults.js +5 -0
  36. package/dist/constants/oauthServerDefaults.js.map +1 -1
  37. package/dist/controllers/activityController.js +6 -5
  38. package/dist/controllers/activityController.js.map +1 -1
  39. package/dist/controllers/authController.js +5 -4
  40. package/dist/controllers/authController.js.map +1 -1
  41. package/dist/controllers/bearerKeyController.js +7 -6
  42. package/dist/controllers/bearerKeyController.js.map +1 -1
  43. package/dist/controllers/betterAuthController.js +2 -1
  44. package/dist/controllers/betterAuthController.js.map +1 -1
  45. package/dist/controllers/builtinPromptController.js +13 -5
  46. package/dist/controllers/builtinPromptController.js.map +1 -1
  47. package/dist/controllers/builtinResourceController.js +14 -6
  48. package/dist/controllers/builtinResourceController.js.map +1 -1
  49. package/dist/controllers/changelogController.js +5 -2
  50. package/dist/controllers/changelogController.js.map +1 -1
  51. package/dist/controllers/cloudController.js +11 -10
  52. package/dist/controllers/cloudController.js.map +1 -1
  53. package/dist/controllers/configController.js +4 -3
  54. package/dist/controllers/configController.js.map +1 -1
  55. package/dist/controllers/contextCostController.js +3 -2
  56. package/dist/controllers/contextCostController.js.map +1 -1
  57. package/dist/controllers/groupController.js +2 -1
  58. package/dist/controllers/groupController.js.map +1 -1
  59. package/dist/controllers/healthController.js +2 -1
  60. package/dist/controllers/healthController.js.map +1 -1
  61. package/dist/controllers/hostedInternalController.js +2 -1
  62. package/dist/controllers/hostedInternalController.js.map +1 -1
  63. package/dist/controllers/logController.js +5 -4
  64. package/dist/controllers/logController.js.map +1 -1
  65. package/dist/controllers/mcpbController.js +23 -20
  66. package/dist/controllers/mcpbController.js.map +1 -1
  67. package/dist/controllers/oauthCallbackController.js +54 -27
  68. package/dist/controllers/oauthCallbackController.js.map +1 -1
  69. package/dist/controllers/oauthClientController.js +7 -6
  70. package/dist/controllers/oauthClientController.js.map +1 -1
  71. package/dist/controllers/oauthDynamicRegistrationController.js +22 -4
  72. package/dist/controllers/oauthDynamicRegistrationController.js.map +1 -1
  73. package/dist/controllers/oauthServerController.js +62 -15
  74. package/dist/controllers/oauthServerController.js.map +1 -1
  75. package/dist/controllers/openApiController.js +7 -6
  76. package/dist/controllers/openApiController.js.map +1 -1
  77. package/dist/controllers/promptController.js +2 -1
  78. package/dist/controllers/promptController.js.map +1 -1
  79. package/dist/controllers/registryController.js +4 -3
  80. package/dist/controllers/registryController.js.map +1 -1
  81. package/dist/controllers/serverController.js +154 -65
  82. package/dist/controllers/serverController.js.map +1 -1
  83. package/dist/controllers/templateController.js +3 -2
  84. package/dist/controllers/templateController.js.map +1 -1
  85. package/dist/controllers/toolController.js +2 -1
  86. package/dist/controllers/toolController.js.map +1 -1
  87. package/dist/dao/DaoFactory.js +3 -2
  88. package/dist/dao/DaoFactory.js.map +1 -1
  89. package/dist/dao/ServerDao.js.map +1 -1
  90. package/dist/dao/UserConfigDaoDbImpl.js.map +1 -1
  91. package/dist/dao/UserDao.js.map +1 -1
  92. package/dist/dao/base/JsonFileBaseDao.js +3 -2
  93. package/dist/dao/base/JsonFileBaseDao.js.map +1 -1
  94. package/dist/dao/examples.js +35 -34
  95. package/dist/dao/examples.js.map +1 -1
  96. package/dist/db/connection.js +59 -58
  97. package/dist/db/connection.js.map +1 -1
  98. package/dist/db/index.js +2 -1
  99. package/dist/db/index.js.map +1 -1
  100. package/dist/db/repositories/ServerRepository.js +3 -3
  101. package/dist/db/repositories/ServerRepository.js.map +1 -1
  102. package/dist/db/repositories/SystemConfigRepository.js +2 -1
  103. package/dist/db/repositories/SystemConfigRepository.js.map +1 -1
  104. package/dist/db/repositories/UserConfigRepository.js.map +1 -1
  105. package/dist/db/repositories/VectorEmbeddingRepository.js +6 -5
  106. package/dist/db/repositories/VectorEmbeddingRepository.js.map +1 -1
  107. package/dist/index.js +28 -19
  108. package/dist/index.js.map +1 -1
  109. package/dist/middlewares/auth.js +4 -3
  110. package/dist/middlewares/auth.js.map +1 -1
  111. package/dist/middlewares/index.js +2 -1
  112. package/dist/middlewares/index.js.map +1 -1
  113. package/dist/middlewares/userContext.js +7 -6
  114. package/dist/middlewares/userContext.js.map +1 -1
  115. package/dist/models/OAuth.js +7 -6
  116. package/dist/models/OAuth.js.map +1 -1
  117. package/dist/models/User.js +13 -12
  118. package/dist/models/User.js.map +1 -1
  119. package/dist/routes/index.js +11 -10
  120. package/dist/routes/index.js.map +1 -1
  121. package/dist/scripts/dao-demo.js +28 -27
  122. package/dist/scripts/dao-demo.js.map +1 -1
  123. package/dist/server.js +36 -30
  124. package/dist/server.js.map +1 -1
  125. package/dist/services/activityLoggingService.js +3 -2
  126. package/dist/services/activityLoggingService.js.map +1 -1
  127. package/dist/services/authorizationService.js +28 -0
  128. package/dist/services/authorizationService.js.map +1 -0
  129. package/dist/services/betterAuthConfig.js +1 -4
  130. package/dist/services/betterAuthConfig.js.map +1 -1
  131. package/dist/services/betterAuthSession.js +8 -7
  132. package/dist/services/betterAuthSession.js.map +1 -1
  133. package/dist/services/changelogService.js +5 -2
  134. package/dist/services/changelogService.js.map +1 -1
  135. package/dist/services/cimdClientService.js +195 -0
  136. package/dist/services/cimdClientService.js.map +1 -0
  137. package/dist/services/cloudService.js +10 -9
  138. package/dist/services/cloudService.js.map +1 -1
  139. package/dist/services/contextCostService.js +6 -2
  140. package/dist/services/contextCostService.js.map +1 -1
  141. package/dist/services/groupService.js +8 -7
  142. package/dist/services/groupService.js.map +1 -1
  143. package/dist/services/hostedAuthService.js +3 -2
  144. package/dist/services/hostedAuthService.js.map +1 -1
  145. package/dist/services/hostedControlPlaneClient.js.map +1 -1
  146. package/dist/services/hostedEventSubscriber.js +9 -8
  147. package/dist/services/hostedEventSubscriber.js.map +1 -1
  148. package/dist/services/hostedRuntimeCatalogNames.js +1 -3
  149. package/dist/services/hostedRuntimeCatalogNames.js.map +1 -1
  150. package/dist/services/keepAliveService.js +7 -6
  151. package/dist/services/keepAliveService.js.map +1 -1
  152. package/dist/services/marketService.js +2 -1
  153. package/dist/services/marketService.js.map +1 -1
  154. package/dist/services/mcpOAuthProvider.js +22 -21
  155. package/dist/services/mcpOAuthProvider.js.map +1 -1
  156. package/dist/services/mcpService.js +239 -130
  157. package/dist/services/mcpService.js.map +1 -1
  158. package/dist/services/oauthClientRegistration.js +30 -29
  159. package/dist/services/oauthClientRegistration.js.map +1 -1
  160. package/dist/services/oauthServerService.js +39 -18
  161. package/dist/services/oauthServerService.js.map +1 -1
  162. package/dist/services/oauthService.js +16 -15
  163. package/dist/services/oauthService.js.map +1 -1
  164. package/dist/services/oauthSettingsStore.js +4 -3
  165. package/dist/services/oauthSettingsStore.js.map +1 -1
  166. package/dist/services/openApiGeneratorService.js +1 -3
  167. package/dist/services/openApiGeneratorService.js.map +1 -1
  168. package/dist/services/openApiToolStatsService.js +37 -0
  169. package/dist/services/openApiToolStatsService.js.map +1 -0
  170. package/dist/services/registry.js +2 -1
  171. package/dist/services/registry.js.map +1 -1
  172. package/dist/services/requestContextService.js.map +1 -1
  173. package/dist/services/serverConfigPresenter.js +85 -0
  174. package/dist/services/serverConfigPresenter.js.map +1 -0
  175. package/dist/services/smartRoutingService.js +6 -5
  176. package/dist/services/smartRoutingService.js.map +1 -1
  177. package/dist/services/sseService.js +52 -51
  178. package/dist/services/sseService.js.map +1 -1
  179. package/dist/services/templateService.js +105 -23
  180. package/dist/services/templateService.js.map +1 -1
  181. package/dist/services/toolResultCompressionService.js +8 -5
  182. package/dist/services/toolResultCompressionService.js.map +1 -1
  183. package/dist/services/upstreamOAuthDisconnectService.js +9 -6
  184. package/dist/services/upstreamOAuthDisconnectService.js.map +1 -1
  185. package/dist/services/userService.js +5 -4
  186. package/dist/services/userService.js.map +1 -1
  187. package/dist/services/vectorSearchService.js +126 -112
  188. package/dist/services/vectorSearchService.js.map +1 -1
  189. package/dist/utils/cacheUtils.js +5 -4
  190. package/dist/utils/cacheUtils.js.map +1 -1
  191. package/dist/utils/corsOrigin.js +42 -0
  192. package/dist/utils/corsOrigin.js.map +1 -0
  193. package/dist/utils/dbRetry.js +2 -5
  194. package/dist/utils/dbRetry.js.map +1 -1
  195. package/dist/utils/frontendShell.js +2 -1
  196. package/dist/utils/frontendShell.js.map +1 -1
  197. package/dist/utils/i18n.js.map +1 -1
  198. package/dist/utils/logger.js +170 -0
  199. package/dist/utils/logger.js.map +1 -0
  200. package/dist/utils/migration.js +51 -50
  201. package/dist/utils/migration.js.map +1 -1
  202. package/dist/utils/oauthIssuer.js +57 -0
  203. package/dist/utils/oauthIssuer.js.map +1 -0
  204. package/dist/utils/openApiRequestBody.js +220 -0
  205. package/dist/utils/openApiRequestBody.js.map +1 -0
  206. package/dist/utils/parameterConversion.js +2 -1
  207. package/dist/utils/parameterConversion.js.map +1 -1
  208. package/dist/utils/path.js +9 -8
  209. package/dist/utils/path.js.map +1 -1
  210. package/dist/utils/processTree.js +47 -0
  211. package/dist/utils/processTree.js.map +1 -0
  212. package/dist/utils/proxyTrust.js.map +1 -1
  213. package/dist/utils/rateLimit.js +8 -0
  214. package/dist/utils/rateLimit.js.map +1 -1
  215. package/dist/utils/requireAdmin.js +15 -0
  216. package/dist/utils/requireAdmin.js.map +1 -0
  217. package/dist/utils/serverConfigPersistence.js +3 -0
  218. package/dist/utils/serverConfigPersistence.js.map +1 -1
  219. package/dist/utils/serverConfigValidation.js +13 -0
  220. package/dist/utils/serverConfigValidation.js.map +1 -0
  221. package/dist/utils/serverNameValidation.js +56 -0
  222. package/dist/utils/serverNameValidation.js.map +1 -0
  223. package/dist/utils/serverShutdown.js +3 -2
  224. package/dist/utils/serverShutdown.js.map +1 -1
  225. package/dist/utils/smartRouting.js +4 -3
  226. package/dist/utils/smartRouting.js.map +1 -1
  227. package/dist/utils/ssrf.js +23 -10
  228. package/dist/utils/ssrf.js.map +1 -1
  229. package/dist/utils/systemConfigCache.js +40 -2
  230. package/dist/utils/systemConfigCache.js.map +1 -1
  231. package/dist/utils/tokenCost.js.map +1 -1
  232. package/dist/utils/tokenTruncation.js +9 -4
  233. package/dist/utils/tokenTruncation.js.map +1 -1
  234. package/dist/utils/version.js +3 -2
  235. package/dist/utils/version.js.map +1 -1
  236. package/frontend/dist/assets/{ActivityPage-Dx53zxEM.js → ActivityPage-Du9Nh-Jy.js} +2 -2
  237. package/frontend/dist/assets/{ActivityPage-Dx53zxEM.js.map → ActivityPage-Du9Nh-Jy.js.map} +1 -1
  238. package/frontend/dist/assets/ConfirmDialog-3APts2Y9.js +2 -0
  239. package/frontend/dist/assets/ConfirmDialog-3APts2Y9.js.map +1 -0
  240. package/frontend/dist/assets/Dashboard-J4G-AZkI.js +2 -0
  241. package/frontend/dist/assets/{Dashboard-ZYVsSvOw.js.map → Dashboard-J4G-AZkI.js.map} +1 -1
  242. package/frontend/dist/assets/{DeleteDialog-DNe-zTEl.js → DeleteDialog-D9ycSRvn.js} +2 -2
  243. package/frontend/dist/assets/{DeleteDialog-DNe-zTEl.js.map → DeleteDialog-D9ycSRvn.js.map} +1 -1
  244. package/frontend/dist/assets/{EndpointCopy-ngxLPeDF.js → EndpointCopy-DFeLcU9A.js} +2 -2
  245. package/frontend/dist/assets/{EndpointCopy-ngxLPeDF.js.map → EndpointCopy-DFeLcU9A.js.map} +1 -1
  246. package/frontend/dist/assets/GroupsPage-CGMZBiXd.js +33 -0
  247. package/frontend/dist/assets/{GroupsPage-JPtupGpI.js.map → GroupsPage-CGMZBiXd.js.map} +1 -1
  248. package/frontend/dist/assets/{LoginPage-B6rU3Ugr.js → LoginPage-g5pJLBk0.js} +2 -2
  249. package/frontend/dist/assets/{LoginPage-B6rU3Ugr.js.map → LoginPage-g5pJLBk0.js.map} +1 -1
  250. package/frontend/dist/assets/{LogsPage-CXq382fb.js → LogsPage-Bkv7xhNf.js} +2 -2
  251. package/frontend/dist/assets/{LogsPage-CXq382fb.js.map → LogsPage-Bkv7xhNf.js.map} +1 -1
  252. package/frontend/dist/assets/MarketPage-WkLrckIr.js +3 -0
  253. package/frontend/dist/assets/MarketPage-WkLrckIr.js.map +1 -0
  254. package/frontend/dist/assets/{OAuthConsentPage-C-n6tv3-.js → OAuthConsentPage-CA35XoBK.js} +2 -2
  255. package/frontend/dist/assets/{OAuthConsentPage-C-n6tv3-.js.map → OAuthConsentPage-CA35XoBK.js.map} +1 -1
  256. package/frontend/dist/assets/{Pagination-DLSuQ8hw.js → Pagination-Dwko3g48.js} +2 -2
  257. package/frontend/dist/assets/{Pagination-DLSuQ8hw.js.map → Pagination-Dwko3g48.js.map} +1 -1
  258. package/frontend/dist/assets/{PromptsPage-BLpy76K4.js → PromptsPage-BuDdW69R.js} +2 -2
  259. package/frontend/dist/assets/{PromptsPage-BLpy76K4.js.map → PromptsPage-BuDdW69R.js.map} +1 -1
  260. package/frontend/dist/assets/{ResourcesPage-C5nynRqE.js → ResourcesPage-Cu-eD5e2.js} +2 -2
  261. package/frontend/dist/assets/{ResourcesPage-C5nynRqE.js.map → ResourcesPage-Cu-eD5e2.js.map} +1 -1
  262. package/frontend/dist/assets/{ServersPage-Ca5W9cPW.js → ServersPage-B1Np5x3N.js} +8 -8
  263. package/frontend/dist/assets/ServersPage-B1Np5x3N.js.map +1 -0
  264. package/frontend/dist/assets/{SettingsPage-DgFBjp_O.js → SettingsPage-BQN8-ZQh.js} +4 -4
  265. package/frontend/dist/assets/SettingsPage-BQN8-ZQh.js.map +1 -0
  266. package/frontend/dist/assets/{StatusDot-CmSkukx0.js → StatusDot-WyvgqplP.js} +2 -2
  267. package/frontend/dist/assets/{StatusDot-CmSkukx0.js.map → StatusDot-WyvgqplP.js.map} +1 -1
  268. package/frontend/dist/assets/{ToggleGroup-sG_2LQom.js → ToggleGroup-CmeedsZU.js} +2 -2
  269. package/frontend/dist/assets/{ToggleGroup-sG_2LQom.js.map → ToggleGroup-CmeedsZU.js.map} +1 -1
  270. package/frontend/dist/assets/UsersPage-DqDFrIIW.js +2 -0
  271. package/frontend/dist/assets/UsersPage-DqDFrIIW.js.map +1 -0
  272. package/frontend/dist/assets/contextCost-CilWqGWT.js +2 -0
  273. package/frontend/dist/assets/contextCost-CilWqGWT.js.map +1 -0
  274. package/frontend/dist/assets/{framework-vendor-I9Y6AgD5.js → framework-vendor-BHAQX05P.js} +3 -3
  275. package/frontend/dist/assets/{framework-vendor-I9Y6AgD5.js.map → framework-vendor-BHAQX05P.js.map} +1 -1
  276. package/frontend/dist/assets/{i18n-vendor-y9bLzIeh.js → i18n-vendor-CMcB7XUR.js} +2 -2
  277. package/frontend/dist/assets/{i18n-vendor-y9bLzIeh.js.map → i18n-vendor-CMcB7XUR.js.map} +1 -1
  278. package/frontend/dist/assets/{icons-vendor-BFl4A9fq.js → icons-vendor-HZHHWeTa.js} +2 -2
  279. package/frontend/dist/assets/{icons-vendor-BFl4A9fq.js.map → icons-vendor-HZHHWeTa.js.map} +1 -1
  280. package/frontend/dist/assets/index-BvU2-VT8.js +3 -0
  281. package/frontend/dist/assets/{index-CLugsCsy.js.map → index-BvU2-VT8.js.map} +1 -1
  282. package/frontend/dist/assets/index-DtrCVU1h.css +1 -0
  283. package/frontend/dist/assets/passwordValidation-DTLWgUy9.js +2 -0
  284. package/frontend/dist/assets/passwordValidation-DTLWgUy9.js.map +1 -0
  285. package/frontend/dist/assets/{resourceService-33rS8QGJ.js → resourceService-BFH4w47h.js} +2 -2
  286. package/frontend/dist/assets/{resourceService-33rS8QGJ.js.map → resourceService-BFH4w47h.js.map} +1 -1
  287. package/frontend/dist/assets/useCostData-CoM1SxdS.js +2 -0
  288. package/frontend/dist/assets/useCostData-CoM1SxdS.js.map +1 -0
  289. package/frontend/dist/assets/useSettingsData-DKrECrG1.js +2 -0
  290. package/frontend/dist/assets/{useSettingsData-fK2bv9oN.js.map → useSettingsData-DKrECrG1.js.map} +1 -1
  291. package/frontend/dist/assets/variableDetection-CIrYDjrq.js +16 -0
  292. package/frontend/dist/assets/variableDetection-CIrYDjrq.js.map +1 -0
  293. package/frontend/dist/index.html +5 -5
  294. package/package.json +2 -2
  295. package/frontend/dist/assets/ConfirmDialog-CYfXEuyU.js +0 -2
  296. package/frontend/dist/assets/ConfirmDialog-CYfXEuyU.js.map +0 -1
  297. package/frontend/dist/assets/Dashboard-ZYVsSvOw.js +0 -2
  298. package/frontend/dist/assets/GroupsPage-JPtupGpI.js +0 -33
  299. package/frontend/dist/assets/MarketPage-BGnQQyXK.js +0 -3
  300. package/frontend/dist/assets/MarketPage-BGnQQyXK.js.map +0 -1
  301. package/frontend/dist/assets/ServersPage-Ca5W9cPW.js.map +0 -1
  302. package/frontend/dist/assets/SettingsPage-DgFBjp_O.js.map +0 -1
  303. package/frontend/dist/assets/UsersPage-PHkFXLL8.js +0 -2
  304. package/frontend/dist/assets/UsersPage-PHkFXLL8.js.map +0 -1
  305. package/frontend/dist/assets/contextCost-D_iNNbhX.js +0 -2
  306. package/frontend/dist/assets/contextCost-D_iNNbhX.js.map +0 -1
  307. package/frontend/dist/assets/index-CGvImZ3F.css +0 -1
  308. package/frontend/dist/assets/index-CLugsCsy.js +0 -3
  309. package/frontend/dist/assets/useSettingsData-fK2bv9oN.js +0 -2
  310. package/frontend/dist/assets/variableDetection-ByJ_cnxa.js +0 -16
  311. package/frontend/dist/assets/variableDetection-ByJ_cnxa.js.map +0 -1
@@ -1,10 +1,57 @@
1
1
  import axios from 'axios';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
2
4
  import { CookieJar } from 'tough-cookie';
3
5
  import SwaggerParser from '@apidevtools/swagger-parser';
4
6
  import * as yaml from 'js-yaml';
5
- import { assertSafeUrl, UnsafeUrlError } from '../utils/ssrf.js';
7
+ import { assertSafeUrl, UnsafeUrlError, createRedirectValidatingFetch } from '../utils/ssrf.js';
6
8
  import { getUserDao } from '../dao/index.js';
7
9
  import { sanitizeStringForLogging, createSafeJSON } from '../utils/serialization.js';
10
+ import { buildMultipartParts, encodeFormUrlEncoded, makeMultipartBodySchemaModelFriendly, selectRequestBodyContent, serializeMultipartBody, } from '../utils/openApiRequestBody.js';
11
+ // Encodes a substituted path parameter following OpenAPI's default
12
+ // `style: simple, explode: false`: primitives whole, arrays and objects part
13
+ // by part, joined with literal commas (#1083).
14
+ function encodePathParameterValue(value) {
15
+ if (Array.isArray(value)) {
16
+ return value.map((item) => encodeURIComponent(String(item))).join(',');
17
+ }
18
+ if (value !== null && typeof value === 'object') {
19
+ return Object.entries(value)
20
+ .map(([key, val]) => `${encodeURIComponent(key)},${encodeURIComponent(String(val))}`)
21
+ .join(',');
22
+ }
23
+ return encodeURIComponent(String(value));
24
+ }
25
+ function isPrintableAscii(value) {
26
+ return /^[\x20-\x7E]*$/.test(value);
27
+ }
28
+ // Strict base64 check: the alphabet plus canonical padding must round-trip
29
+ // through decode → encode unchanged. Buffer.from alone is too lenient (it
30
+ // silently drops invalid characters), which would misread raw secrets as
31
+ // encoded ones.
32
+ function tryDecodeBase64Utf8(value) {
33
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
34
+ return null;
35
+ }
36
+ const decoded = Buffer.from(value, 'base64').toString('utf8');
37
+ const stripPadding = (input) => input.replace(/=+$/, '');
38
+ return stripPadding(Buffer.from(decoded, 'utf8').toString('base64')) === stripPadding(value)
39
+ ? decoded
40
+ : null;
41
+ }
42
+ // HTTP Basic transmits base64("user:password"), but users reasonably enter the
43
+ // raw `user:pass` pair into the credentials field (#1079). Encode such values
44
+ // automatically; keep a value only when it decodes to printable
45
+ // `user:pass`-shaped text (contains ':'), the conservative test for a
46
+ // credential that was pre-encoded upstream. Anything ambiguous (e.g. a token
47
+ // without a colon) is treated as raw and encoded.
48
+ function toBasicAuthCredentials(credentials) {
49
+ const decoded = tryDecodeBase64Utf8(credentials);
50
+ if (decoded !== null && isPrintableAscii(decoded) && decoded.includes(':')) {
51
+ return credentials;
52
+ }
53
+ return Buffer.from(credentials, 'utf8').toString('base64');
54
+ }
8
55
  export class OpenAPIClient {
9
56
  constructor(config, options = {}) {
10
57
  this.config = config;
@@ -20,20 +67,47 @@ export class OpenAPIClient {
20
67
  if (!config.openapi?.url && !config.openapi?.schema) {
21
68
  throw new Error('OpenAPI URL or schema is required');
22
69
  }
23
- // Initial baseUrl, will be updated from OpenAPI servers field in initialize()
70
+ // Initial baseUrl, will be updated from OpenAPI servers field in initialize().
71
+ // It is only used to resolve relative request paths (see callTool), never
72
+ // installed as the axios instance's default baseURL. Each request's
73
+ // effective URL is SSRF-validated explicitly before dispatch, so a
74
+ // user-derived default baseURL must not taint this.httpClient — CodeQL
75
+ // tracks a client's default baseURL as the host of every request it makes.
24
76
  this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : '';
25
77
  this.securityConfig = config.openapi.security;
26
78
  this.persistOAuth2Token = options.persistOAuth2Token;
27
- this.httpClient = axios.create({
28
- baseURL: this.baseUrl,
79
+ this.httpClient = this.createHttpClient(config);
80
+ const mainCookie = this.applySecurityToClient(this.httpClient, this.securityConfig);
81
+ this.staticCookieHeader = mainCookie.staticCookieHeader;
82
+ const specSecurity = config.openapi?.specSecurity;
83
+ if (specSecurity && specSecurity.type !== 'none') {
84
+ // The dynamic OAuth2 client-credentials flow persists refreshed tokens
85
+ // back into `security`, so `specSecurity` only supports a static token.
86
+ if ((specSecurity.type === 'oauth2' && !specSecurity.oauth2?.token) ||
87
+ (specSecurity.type === 'openIdConnect' && !specSecurity.openIdConnect?.token)) {
88
+ throw new Error('openapi.specSecurity: oauth2/openIdConnect requires a pre-obtained token; dynamic token fetch is only supported for openapi.security');
89
+ }
90
+ this.specHttpClient = this.createHttpClient(config);
91
+ const specCookie = this.applySecurityToClient(this.specHttpClient, specSecurity);
92
+ this.specStaticCookieHeader = specCookie.staticCookieHeader;
93
+ }
94
+ }
95
+ // Shared instance factory so the main client and the spec-download client
96
+ // (#1079) get identical base behavior: no redirects (credentials must never
97
+ // cross origins), OpenAPI array query serialization, and operator-supplied
98
+ // static headers.
99
+ createHttpClient(config) {
100
+ return axios.create({
29
101
  timeout: config.options?.timeout || 30000,
30
102
  maxRedirects: 0,
103
+ // Serialize array query params per OpenAPI's default `style: form, explode: true`
104
+ // (`id=a&id=b`) instead of axios's bracket form `id[]=a&id[]=b` (#1080).
105
+ paramsSerializer: { indexes: null },
31
106
  headers: {
32
107
  'Content-Type': 'application/json',
33
108
  ...config.headers,
34
109
  },
35
110
  });
36
- this.setupSecurity();
37
111
  }
38
112
  extractBaseUrl(specUrl) {
39
113
  try {
@@ -45,57 +119,68 @@ export class OpenAPIClient {
45
119
  return '';
46
120
  }
47
121
  }
48
- setupSecurity() {
49
- if (!this.securityConfig || this.securityConfig.type === 'none') {
50
- return;
122
+ // Apply one OpenAPISecurityConfig onto a specific axios instance: default
123
+ // headers for header-based credentials, a request interceptor for query
124
+ // params, and a returned static cookie for the caller to manage (cookies
125
+ // cannot live in axios defaults). Both `security` and `specSecurity` (#1079)
126
+ // go through here so the two credential slots behave identically.
127
+ applySecurityToClient(client, securityConfig) {
128
+ if (!securityConfig || securityConfig.type === 'none') {
129
+ return {};
51
130
  }
52
- switch (this.securityConfig.type) {
53
- case 'apiKey':
54
- if (this.securityConfig.apiKey) {
55
- const { name, in: location, value } = this.securityConfig.apiKey;
56
- if (location === 'header') {
57
- this.httpClient.defaults.headers.common[name] = value;
131
+ switch (securityConfig.type) {
132
+ case 'apiKey': {
133
+ if (securityConfig.apiKey) {
134
+ const { name, in: location, value } = securityConfig.apiKey;
135
+ // An empty/absent value means the credential has not been supplied
136
+ // yet ("needs credentials"); skip injection rather than sending a
137
+ // broken empty header/param/cookie (#1077).
138
+ if (location === 'header' && value) {
139
+ client.defaults.headers.common[name] = value;
58
140
  }
59
- else if (location === 'query') {
60
- this.httpClient.interceptors.request.use((config) => {
141
+ else if (location === 'query' && value) {
142
+ client.interceptors.request.use((config) => {
61
143
  config.params = { ...config.params, [name]: value };
62
144
  return config;
63
145
  });
64
146
  }
65
- else if (location === 'cookie') {
66
- this.staticCookieHeader = `${name}=${value}`;
147
+ else if (location === 'cookie' && value) {
148
+ return { staticCookieHeader: `${name}=${value}` };
67
149
  }
68
150
  }
69
- break;
70
- case 'http':
71
- if (this.securityConfig.http) {
72
- const { scheme, credentials } = this.securityConfig.http;
151
+ return {};
152
+ }
153
+ case 'http': {
154
+ if (securityConfig.http) {
155
+ const { scheme, credentials } = securityConfig.http;
73
156
  if (scheme === 'bearer' && credentials) {
74
- this.httpClient.defaults.headers.common['Authorization'] = `Bearer ${credentials}`;
157
+ client.defaults.headers.common['Authorization'] = `Bearer ${credentials}`;
75
158
  }
76
159
  else if (scheme === 'basic' && credentials) {
77
- this.httpClient.defaults.headers.common['Authorization'] = `Basic ${credentials}`;
160
+ // Accept the raw `user:pass` form and pre-encoded base64 alike (#1079).
161
+ const trimmed = credentials.trim();
162
+ if (trimmed) {
163
+ client.defaults.headers.common['Authorization'] =
164
+ `Basic ${toBasicAuthCredentials(trimmed)}`;
165
+ }
78
166
  }
79
167
  }
80
- break;
168
+ return {};
169
+ }
81
170
  case 'oauth2':
82
- if (this.securityConfig.oauth2?.token) {
83
- this.setAuthorizationHeader(this.securityConfig.oauth2.token);
84
- }
85
- break;
171
+ this.setAuthorizationHeader(client, securityConfig.oauth2?.token);
172
+ return {};
86
173
  case 'openIdConnect':
87
- if (this.securityConfig.openIdConnect?.token) {
88
- this.setAuthorizationHeader(this.securityConfig.openIdConnect.token);
89
- }
90
- break;
174
+ this.setAuthorizationHeader(client, securityConfig.openIdConnect?.token);
175
+ return {};
91
176
  }
92
177
  }
93
- setAuthorizationHeader(token) {
178
+ setAuthorizationHeader(client, token) {
94
179
  if (token) {
95
- this.httpClient.defaults.headers.common['Authorization'] = 'Bearer ' + token;
180
+ client.defaults.headers.common['Authorization'] = 'Bearer ' + token;
96
181
  return;
97
182
  }
98
- delete this.httpClient.defaults.headers.common['Authorization'];
183
+ delete client.defaults.headers.common['Authorization'];
99
184
  }
100
185
  getOAuth2Config() {
101
186
  return this.securityConfig?.type === 'oauth2' ? this.securityConfig.oauth2 : undefined;
@@ -114,7 +199,7 @@ export class OpenAPIClient {
114
199
  if (this.config.openapi?.security?.oauth2) {
115
200
  this.config.openapi.security.oauth2 = oauth2;
116
201
  }
117
- this.setAuthorizationHeader(undefined);
202
+ this.setAuthorizationHeader(this.httpClient, undefined);
118
203
  await this.persistOAuth2Token?.({ ...oauth2 });
119
204
  return true;
120
205
  }
@@ -142,7 +227,7 @@ export class OpenAPIClient {
142
227
  if (this.config.openapi?.security?.oauth2) {
143
228
  this.config.openapi.security.oauth2 = oauth2;
144
229
  }
145
- this.setAuthorizationHeader(token);
230
+ this.setAuthorizationHeader(this.httpClient, token);
146
231
  await this.persistOAuth2Token?.(oauth2);
147
232
  }
148
233
  async fetchOAuth2ClientCredentialsToken(oauth2) {
@@ -159,9 +244,15 @@ export class OpenAPIClient {
159
244
  if (Array.isArray(oauth2.scopes) && oauth2.scopes.length > 0) {
160
245
  body.set('scope', oauth2.scopes.join(' '));
161
246
  }
247
+ // Validate OAuth token endpoints with the same owner-scoped SSRF policy as
248
+ // specification and tool requests. This is especially important for the
249
+ // unsaved preview endpoint, which accepts the complete OpenAPI config.
250
+ const safeTokenUrl = await assertSafeUrl(oauth2.tokenUrl, {
251
+ allowInternal: this.allowInternalNetworks,
252
+ });
162
253
  const response = await this.httpClient.request({
163
254
  method: 'post',
164
- url: oauth2.tokenUrl,
255
+ url: safeTokenUrl,
165
256
  baseURL: undefined,
166
257
  headers: {
167
258
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -184,12 +275,12 @@ export class OpenAPIClient {
184
275
  return undefined;
185
276
  }
186
277
  if (this.hasValidOAuth2Token(oauth2)) {
187
- this.setAuthorizationHeader(oauth2.token);
278
+ this.setAuthorizationHeader(this.httpClient, oauth2.token);
188
279
  return oauth2.token;
189
280
  }
190
281
  if (!oauth2.tokenUrl || !oauth2.clientId) {
191
282
  if (oauth2.token) {
192
- this.setAuthorizationHeader(oauth2.token);
283
+ this.setAuthorizationHeader(this.httpClient, oauth2.token);
193
284
  }
194
285
  return oauth2.token;
195
286
  }
@@ -214,33 +305,46 @@ export class OpenAPIClient {
214
305
  // document download (for url configs) and is ready for later API calls.
215
306
  // No-op when no OAuth2 security is configured.
216
307
  await this.ensureOAuth2AccessToken();
308
+ // Reset the external-$ref guard marker so a rejection from THIS
309
+ // dereference pass is never confused with a stale one.
310
+ this.refGuardRejection = undefined;
217
311
  // Parse and dereference the OpenAPI specification
218
312
  if (this.config.openapi?.url) {
219
- // Fetch the document through the authenticated httpClient (carries
220
- // config.headers + security credentials from setupSecurity, and uses
221
- // maxRedirects: 0 so credentials are never forwarded across a
222
- // cross-origin redirect). SwaggerParser's own resolver is bypassed for
223
- // the main document so its (unauthenticated) headers never see the
224
- // credentials; external $ref resolution still uses that resolver and
225
- // therefore receives no auth by default.
313
+ // SwaggerParser's own resolver is bypassed for the main document so
314
+ // its (unauthenticated) headers never see the credentials; external
315
+ // $ref resolution still uses that resolver and therefore receives no
316
+ // auth by default.
226
317
  const specUrl = this.config.openapi.url;
227
- await assertSafeUrl(specUrl, { allowInternal: this.allowInternalNetworks });
318
+ const safeSpecUrl = await assertSafeUrl(specUrl, {
319
+ allowInternal: this.allowInternalNetworks,
320
+ });
228
321
  const requestConfig = {
229
322
  responseType: 'text',
230
323
  transformResponse: [(data) => data],
231
324
  };
325
+ // Download through the client whose credentials match the spec
326
+ // endpoint: the dedicated specHttpClient when `specSecurity` names one
327
+ // (#1079), otherwise the main client whose defaults already carry
328
+ // `security` (and the apiKey-in-query interceptor). Uses maxRedirects:
329
+ // 0 so credentials are never forwarded across a cross-origin redirect;
330
+ // external $ref resolution keeps using the unauthenticated resolver
331
+ // from guardedRefResolveOptions below.
332
+ const specClient = this.specHttpClient ?? this.httpClient;
333
+ const specCookie = this.specHttpClient
334
+ ? this.specStaticCookieHeader
335
+ : this.staticCookieHeader;
232
336
  // The static apiKey.in:'cookie' value is otherwise only injected in
233
- // callTool; apply it here too so cookie-protected spec URLs load.
234
- if (this.staticCookieHeader) {
235
- requestConfig.headers = { Cookie: this.staticCookieHeader };
337
+ // callTool; apply it here so cookie-protected spec URLs load.
338
+ if (specCookie) {
339
+ requestConfig.headers = { Cookie: specCookie };
236
340
  }
237
- const response = await this.httpClient.get(specUrl, requestConfig);
341
+ const response = await specClient.get(safeSpecUrl, requestConfig);
238
342
  const raw = typeof response.data === 'string' ? response.data : String(response.data);
239
- this.spec = (await SwaggerParser.dereference(specUrl, this.parseSpecDocument(raw), {}));
343
+ this.spec = (await SwaggerParser.dereference(safeSpecUrl, this.parseSpecDocument(raw), this.guardedRefResolveOptions()));
240
344
  }
241
345
  else if (this.config.openapi?.schema) {
242
346
  // For schema object, we need to pass it as a cloned object
243
- this.spec = (await SwaggerParser.dereference(JSON.parse(JSON.stringify(this.config.openapi.schema))));
347
+ this.spec = (await SwaggerParser.dereference(JSON.parse(JSON.stringify(this.config.openapi.schema)), this.guardedRefResolveOptions()));
244
348
  }
245
349
  else {
246
350
  throw new Error('Either OpenAPI URL or schema must be provided');
@@ -255,6 +359,12 @@ export class OpenAPIClient {
255
359
  if (error instanceof UnsafeUrlError) {
256
360
  throw error;
257
361
  }
362
+ // SwaggerParser wraps resolver failures (including our SSRF guard
363
+ // rejections) in its own ResolverError and drops the cause — surface the
364
+ // real UnsafeUrlError when the guarded $ref resolver flagged one.
365
+ if (this.refGuardRejection) {
366
+ throw this.refGuardRejection;
367
+ }
258
368
  const errorMessage = error instanceof Error ? error.message : String(error);
259
369
  throw new Error(`Failed to load OpenAPI specification: ${sanitizeStringForLogging(errorMessage)}`);
260
370
  }
@@ -270,6 +380,57 @@ export class OpenAPIClient {
270
380
  return yaml.load(raw);
271
381
  }
272
382
  }
383
+ // SwaggerParser options that replace its built-in resolvers so EVERY external
384
+ // $ref target is validated by the SSRF guard before any request leaves the
385
+ // process. Without this, a non-admin-supplied spec could point $refs at
386
+ // internal addresses (IMDS, RFC1918, loopback) or local files and have their
387
+ // contents embedded into the dereferenced schema served back to clients.
388
+ // Admin-owned servers keep the same allowInternal escape hatch as the main
389
+ // document download and callTool.
390
+ guardedRefResolveOptions() {
391
+ const rejectUnsafe = (err) => {
392
+ this.refGuardRejection = err;
393
+ throw err;
394
+ };
395
+ return {
396
+ resolve: {
397
+ http: {
398
+ order: 1,
399
+ canRead: (file) => /^https?:\/\//i.test(file.url),
400
+ read: async (file) => {
401
+ let safeRefUrl;
402
+ try {
403
+ safeRefUrl = await assertSafeUrl(file.url, {
404
+ allowInternal: this.allowInternalNetworks,
405
+ });
406
+ }
407
+ catch (error) {
408
+ rejectUnsafe(error);
409
+ return '';
410
+ }
411
+ // Plain fetch, NOT this.httpClient: default auth headers and
412
+ // interceptors must never be forwarded cross-origin to $ref
413
+ // targets (#1044). Redirects are followed manually with every hop
414
+ // re-validated by the guard.
415
+ const safeFetch = createRedirectValidatingFetch((url, init) => fetch(url, init), this.allowInternalNetworks);
416
+ const response = await safeFetch(safeRefUrl, { method: 'GET', headers: {} });
417
+ const buf = await response.arrayBuffer();
418
+ return new TextDecoder().decode(buf);
419
+ },
420
+ },
421
+ file: {
422
+ order: 1,
423
+ canRead: true,
424
+ read: async (file) => {
425
+ if (!this.allowInternalNetworks) {
426
+ rejectUnsafe(new UnsafeUrlError(`Blocked local file reference in specification: ${sanitizeStringForLogging(file.url)}`));
427
+ }
428
+ return readFile(new URL(file.url), 'utf8');
429
+ },
430
+ },
431
+ },
432
+ };
433
+ }
273
434
  generateOperationName(method, path) {
274
435
  // Clean path, remove parameter brackets and special characters
275
436
  const cleanPath = path
@@ -321,8 +482,6 @@ export class OpenAPIClient {
321
482
  this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
322
483
  }
323
484
  }
324
- // Update HTTP client's baseURL
325
- this.httpClient.defaults.baseURL = this.baseUrl;
326
485
  }
327
486
  extractTools() {
328
487
  if (!this.spec?.paths) {
@@ -364,15 +523,30 @@ export class OpenAPIClient {
364
523
  operationName = uniqueName;
365
524
  }
366
525
  generatedNames.add(operationName);
526
+ // Resolve the operation's declared request-body content type once so
527
+ // schema advertisement below and outgoing serialization in callTool
528
+ // stay symmetric (#1078).
529
+ const declaredRequestBody = operation.requestBody && 'content' in operation.requestBody
530
+ ? operation.requestBody
531
+ : undefined;
532
+ const requestBodySelection = selectRequestBodyContent(declaredRequestBody);
533
+ let description = operation.summary || operation.description || `${method.toUpperCase()} ${path}`;
534
+ // A body whose content type the hub cannot serialize used to produce a
535
+ // silent zero-argument tool that models would retry forever. Mark such
536
+ // operations visibly in the description instead (#1078).
537
+ if (declaredRequestBody && !requestBodySelection) {
538
+ const declaredTypes = Object.keys(declaredRequestBody.content ?? {}).join(', ');
539
+ description += ` [Unsupported request body content type(s): ${declaredTypes} — only application/json, application/x-www-form-urlencoded and multipart/form-data can be sent by MCPHub.]`;
540
+ }
367
541
  const tool = {
368
542
  name: operationName,
369
- description: operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
543
+ description,
370
544
  // SwaggerParser.dereference turns recursive $ref schemas into live
371
545
  // circular references on the dereferenced spec objects. generateInputSchema
372
546
  // references those objects directly, so without sanitization every
373
547
  // downstream serializer (tokenCost, getServerConfig, MCP ListTools,
374
548
  // embeddings) throws "Converting circular structure to JSON". See #959.
375
- inputSchema: createSafeJSON(this.generateInputSchema(operation, path, method)),
549
+ inputSchema: createSafeJSON(this.generateInputSchema(operation, path, method, requestBodySelection)),
376
550
  operationId: operation.operationId || operationName,
377
551
  method: method,
378
552
  path,
@@ -384,7 +558,7 @@ export class OpenAPIClient {
384
558
  }
385
559
  }
386
560
  }
387
- generateInputSchema(operation, _path, _method) {
561
+ generateInputSchema(operation, _path, _method, requestBodySelection) {
388
562
  const schema = {
389
563
  type: 'object',
390
564
  properties: {},
@@ -425,12 +599,18 @@ export class OpenAPIClient {
425
599
  }
426
600
  }
427
601
  }
428
- // Handle request body
429
- if (operation.requestBody && 'content' in operation.requestBody) {
602
+ // Handle request body. The advertised schema must match what callTool can
603
+ // serialize, so key off the same resolved content type selection (#1078):
604
+ // JSON and urlencoded bodies are exposed as-is; multipart bodies are
605
+ // rewritten so binary fields accept base64-encoded strings.
606
+ if (operation.requestBody && 'content' in operation.requestBody && requestBodySelection) {
430
607
  const requestBody = operation.requestBody;
431
- const jsonContent = requestBody.content?.['application/json'];
432
- if (jsonContent?.schema) {
433
- properties['body'] = jsonContent.schema;
608
+ const selectedSchema = requestBodySelection.mediaType.schema;
609
+ if (selectedSchema) {
610
+ properties['body'] =
611
+ requestBodySelection.contentType === 'multipart/form-data'
612
+ ? makeMultipartBodySchemaModelFriendly(selectedSchema)
613
+ : selectedSchema;
434
614
  if (requestBody.required) {
435
615
  required.push('body');
436
616
  }
@@ -466,9 +646,14 @@ export class OpenAPIClient {
466
646
  const pathParams = tool.parameters?.filter((p) => p.in === 'path') || [];
467
647
  for (const param of pathParams) {
468
648
  const value = args[param.name];
469
- if (value !== undefined) {
470
- url = url.replace(`{${param.name}}`, String(value));
649
+ if (value === undefined || value === null) {
650
+ // Path parameters are required by the OpenAPI spec; fail fast rather
651
+ // than sending a request whose `{placeholder}` can only 404 (#1083).
652
+ throw new Error(`Required path parameter '${param.name}' is missing`);
471
653
  }
654
+ // Values come from model output, so encode them to keep URL-significant
655
+ // characters ('/', '?', '#', '%') from changing the endpoint (#1083).
656
+ url = url.replace(`{${param.name}}`, encodePathParameterValue(value));
472
657
  }
473
658
  // Build query parameters
474
659
  const queryParams = {};
@@ -485,9 +670,41 @@ export class OpenAPIClient {
485
670
  url,
486
671
  params: queryParams,
487
672
  };
488
- // Add request body if applicable
489
- if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
490
- requestConfig.data = args.body;
673
+ // Add request body if applicable. Key off the operation's own spec rather
674
+ // than a method allowlist: some APIs declare required bodies on DELETE
675
+ // (bulk deletes), and RFC 9110 §9.3.5 permits content when the origin
676
+ // server has indicated support for it — which a requestBody declaration
677
+ // in its OpenAPI document is. Keeps schema advertisement and sending
678
+ // symmetric (#1084). The body is serialized according to the content type
679
+ // resolved from the same spec the tool schema was built from (#1078).
680
+ let requestBodyContentType;
681
+ if (args.body !== undefined && tool.requestBody) {
682
+ const selection = selectRequestBodyContent(tool.requestBody);
683
+ if (!selection) {
684
+ const declaredTypes = Object.keys(tool.requestBody.content ?? {}).join(', ');
685
+ throw new Error(`Tool '${toolName}' declares a request body with unsupported content type(s): ${declaredTypes}. Only application/json, application/x-www-form-urlencoded and multipart/form-data can be sent by MCPHub.`);
686
+ }
687
+ switch (selection.contentType) {
688
+ case 'application/json':
689
+ requestConfig.data = args.body;
690
+ break;
691
+ case 'application/x-www-form-urlencoded':
692
+ requestConfig.data = encodeFormUrlEncoded(args.body);
693
+ requestBodyContentType = 'application/x-www-form-urlencoded';
694
+ break;
695
+ case 'multipart/form-data': {
696
+ const boundary = `----MCPHubBoundary${randomUUID().replace(/-/g, '')}`;
697
+ // The spec is dereferenced before tools are built, so the schema
698
+ // cannot still be a $ref here.
699
+ const bodySchema = selection.mediaType.schema;
700
+ const parts = buildMultipartParts(args.body, bodySchema);
701
+ requestConfig.data = serializeMultipartBody(parts, boundary);
702
+ // The boundary must travel with the header, so it is set explicitly
703
+ // per request rather than left to axios.
704
+ requestBodyContentType = `multipart/form-data; boundary=${boundary}`;
705
+ break;
706
+ }
707
+ }
491
708
  }
492
709
  // Collect all headers to be sent
493
710
  const allHeaders = {};
@@ -507,6 +724,11 @@ export class OpenAPIClient {
507
724
  }
508
725
  }
509
726
  }
727
+ // Form and multipart bodies override the client-wide JSON Content-Type
728
+ // default for this request only (#1078).
729
+ if (requestBodyContentType) {
730
+ allHeaders['Content-Type'] = requestBodyContentType;
731
+ }
510
732
  // Set headers if any were collected
511
733
  if (Object.keys(allHeaders).length > 0) {
512
734
  requestConfig.headers = allHeaders;
@@ -517,15 +739,34 @@ export class OpenAPIClient {
517
739
  // final URL rather than trusting either alone.
518
740
  resolvedTarget = null;
519
741
  try {
520
- resolvedTarget = new URL(String(requestConfig.url ?? '/'), this.baseUrl || undefined);
742
+ // Join the operation path onto the server-declared base path with
743
+ // append semantics, matching axios's combineURLs before #937 and the
744
+ // OpenAPI servers+paths model: `new URL` reference resolution would
745
+ // drop the base path for root-absolute paths ('/ping' against
746
+ // 'http://host/api' resolves to '/ping'), 404-ing every tool call
747
+ // (#1098). Absolute URLs bypass the join and resolve as-is.
748
+ let joinedPath = String(requestConfig.url ?? '/');
749
+ let resolveBase = this.baseUrl || undefined;
750
+ if (resolveBase && !/^https?:\/\//i.test(joinedPath)) {
751
+ const parsedBase = new URL(resolveBase);
752
+ const basePath = parsedBase.pathname.replace(/\/+$/, '');
753
+ if (basePath) {
754
+ joinedPath = `${basePath}/${joinedPath.replace(/^\/+/, '')}`;
755
+ }
756
+ resolveBase = parsedBase.origin;
757
+ }
758
+ resolvedTarget = new URL(joinedPath, resolveBase);
521
759
  }
522
760
  catch {
523
761
  // relative path with no base — no host to validate; axios surfaces the error
524
762
  }
525
763
  if (resolvedTarget) {
526
- await assertSafeUrl(resolvedTarget.href, {
764
+ const safeTargetUrl = await assertSafeUrl(resolvedTarget.href, {
527
765
  allowInternal: this.allowInternalNetworks,
528
766
  });
767
+ const safeTarget = new URL(safeTargetUrl);
768
+ requestConfig.baseURL = safeTarget.origin;
769
+ requestConfig.url = `${safeTarget.pathname}${safeTarget.search}`;
529
770
  }
530
771
  const cookieSessionEnabled = this.isCookieSessionEnabled(sessionId);
531
772
  // Inject cookies when either the dynamic session store is active for this
@@ -548,9 +789,7 @@ export class OpenAPIClient {
548
789
  // a 3xx login redirect is surfaced as an error; 4xx logout responses
549
790
  // may expire cookies. Capture before retry/rethrow so the jar stays
550
791
  // current and the 401-retry path can re-inject updated cookies.
551
- if (this.isCookieSessionEnabled(sessionId) &&
552
- resolvedTarget &&
553
- error.response) {
792
+ if (this.isCookieSessionEnabled(sessionId) && resolvedTarget && error.response) {
554
793
  this.captureResponseCookies(error.response, resolvedTarget.href, sessionId);
555
794
  }
556
795
  if (attemptedUpstreamRequest &&
@@ -595,8 +834,191 @@ export class OpenAPIClient {
595
834
  getSpec() {
596
835
  return this.spec;
597
836
  }
837
+ // ── Declared-security resolution (#1077) ─────────────────────────────────
838
+ //
839
+ // The import path reads the effective `security` requirement the spec
840
+ // declares and maps it onto MCPHub's OpenAPISecurityConfig so the form can
841
+ // prefill type/scheme/name fields. Only structural fields are ever produced
842
+ // — a spec cannot contain the secret, so value / credentials / token stay
843
+ // empty for the user.
844
+ /**
845
+ * Resolve the effective security requirement the (already parsed) spec
846
+ * declares and map it onto MCPHub's security model for form prefill.
847
+ *
848
+ * OpenAPI 3.x rules applied here:
849
+ * - an operation-level `security` overrides the root-level one for that
850
+ * operation; operations without one inherit the root requirement;
851
+ * - `security: []` explicitly disables auth for that scope;
852
+ * - each requirement is an OR-list of named schemes (from
853
+ * `components.securitySchemes`); a single scheme may be repeated in an
854
+ * AND-list with its required scopes.
855
+ *
856
+ * The first requirement whose scheme maps onto MCPHub's model (apiKey,
857
+ * http/basic|bearer, oauth2, openIdConnect) is returned as `prefill`.
858
+ */
859
+ getDeclaredSecurity() {
860
+ const none = {
861
+ declared: false,
862
+ supported: false,
863
+ summary: '',
864
+ alternatives: 0,
865
+ requiresCredentials: false,
866
+ };
867
+ if (!this.spec) {
868
+ return none;
869
+ }
870
+ const rootSecurity = Array.isArray(this.spec.security) ? this.spec.security : undefined;
871
+ // An explicit operation-level requirement is the most specific declaration
872
+ // in the document (it overrides the root default for that operation), so it
873
+ // wins for prefill; the root requirement is the fallback default.
874
+ const effective = this.firstOperationSecurity() ??
875
+ (rootSecurity && rootSecurity.length > 0 ? rootSecurity : undefined);
876
+ if (!effective || effective.length === 0) {
877
+ return none;
878
+ }
879
+ const schemes = this.spec.components?.securitySchemes ?? {};
880
+ return this.mapSecurityRequirement(effective, schemes);
881
+ }
882
+ // Per OpenAPI 3.x, an operation without a `security` field inherits the
883
+ // root-level requirement. Return the first operation that declares one — the
884
+ // most specific security statement in the document — so the prefill reflects
885
+ // what operations actually require rather than only the root default.
886
+ firstOperationSecurity() {
887
+ if (!this.spec?.paths) {
888
+ return undefined;
889
+ }
890
+ const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'];
891
+ for (const pathItem of Object.values(this.spec.paths)) {
892
+ if (!pathItem) {
893
+ continue;
894
+ }
895
+ for (const method of methods) {
896
+ const operation = pathItem[method];
897
+ if (operation?.security && operation.security.length > 0) {
898
+ return operation.security;
899
+ }
900
+ }
901
+ }
902
+ return undefined;
903
+ }
904
+ mapSecurityRequirement(requirement, schemes) {
905
+ let firstUnsupported;
906
+ for (const alternative of requirement) {
907
+ for (const schemeName of Object.keys(alternative)) {
908
+ const scheme = schemes[schemeName];
909
+ if (!scheme || '$ref' in scheme) {
910
+ // Unknown name or an unresolved reference — try the next alternative.
911
+ continue;
912
+ }
913
+ const mapped = this.mapSecurityScheme(schemeName, scheme);
914
+ if (mapped.ok) {
915
+ return {
916
+ declared: true,
917
+ supported: true,
918
+ prefill: mapped.prefill,
919
+ summary: mapped.summary,
920
+ alternatives: requirement.length,
921
+ requiresCredentials: true,
922
+ ...(mapped.cookieHint ? { cookieHint: true } : {}),
923
+ };
924
+ }
925
+ firstUnsupported ?? (firstUnsupported = {
926
+ declared: true,
927
+ supported: false,
928
+ summary: mapped.summary,
929
+ alternatives: requirement.length,
930
+ requiresCredentials: false,
931
+ unsupportedReason: mapped.unsupportedReason,
932
+ });
933
+ }
934
+ }
935
+ return (firstUnsupported ?? {
936
+ declared: true,
937
+ supported: false,
938
+ summary: 'unknown security scheme',
939
+ alternatives: requirement.length,
940
+ requiresCredentials: false,
941
+ unsupportedReason: 'The spec declares a security scheme MCPHub cannot recognize.',
942
+ });
943
+ }
944
+ mapSecurityScheme(schemeName, scheme) {
945
+ switch (scheme.type) {
946
+ case 'http':
947
+ if (scheme.scheme === 'bearer') {
948
+ return {
949
+ ok: true,
950
+ prefill: {
951
+ type: 'http',
952
+ http: {
953
+ scheme: 'bearer',
954
+ ...(scheme.bearerFormat ? { bearerFormat: scheme.bearerFormat } : {}),
955
+ },
956
+ },
957
+ summary: scheme.bearerFormat ? `HTTP bearer (${scheme.bearerFormat})` : 'HTTP bearer',
958
+ };
959
+ }
960
+ if (scheme.scheme === 'basic') {
961
+ return {
962
+ ok: true,
963
+ prefill: { type: 'http', http: { scheme: 'basic' } },
964
+ summary: 'HTTP basic',
965
+ };
966
+ }
967
+ // MCPHub's form can select digest but applySecurityToClient() never
968
+ // emits a
969
+ // Digest header, so declaring it as prefilled would silently mislead.
970
+ return {
971
+ ok: false,
972
+ summary: `HTTP ${scheme.scheme}`,
973
+ unsupportedReason: `HTTP scheme '${scheme.scheme}' cannot be sent by MCPHub.`,
974
+ };
975
+ case 'apiKey': {
976
+ const location = scheme.in;
977
+ if (location === 'header' || location === 'query' || location === 'cookie') {
978
+ return {
979
+ ok: true,
980
+ prefill: { type: 'apiKey', apiKey: { name: scheme.name, in: location } },
981
+ summary: `API key in ${location} '${scheme.name}'`,
982
+ cookieHint: location === 'cookie',
983
+ };
984
+ }
985
+ return {
986
+ ok: false,
987
+ summary: `API key in '${location}'`,
988
+ unsupportedReason: `API key location '${location}' is not supported by MCPHub.`,
989
+ };
990
+ }
991
+ case 'oauth2': {
992
+ // Only flows with a token endpoint are actionable for MCPHub's
993
+ // client-credentials token fetch; prefill it when the spec provides it.
994
+ const tokenUrl = scheme.flows?.clientCredentials?.tokenUrl ||
995
+ scheme.flows?.password?.tokenUrl ||
996
+ scheme.flows?.authorizationCode?.tokenUrl;
997
+ return {
998
+ ok: true,
999
+ prefill: {
1000
+ type: 'oauth2',
1001
+ ...(tokenUrl ? { oauth2: { tokenUrl } } : {}),
1002
+ },
1003
+ summary: 'OAuth2',
1004
+ };
1005
+ }
1006
+ case 'openIdConnect':
1007
+ return {
1008
+ ok: true,
1009
+ prefill: { type: 'openIdConnect', openIdConnect: { url: scheme.openIdConnectUrl } },
1010
+ summary: 'OpenID Connect',
1011
+ };
1012
+ default:
1013
+ return {
1014
+ ok: false,
1015
+ summary: `security scheme '${schemeName}'`,
1016
+ unsupportedReason: `Security scheme type '${scheme.type}' is not supported by MCPHub.`,
1017
+ };
1018
+ }
1019
+ }
598
1020
  isCookieSessionEnabled(sessionId) {
599
- return !!this.config.openapi?.cookieSession && typeof sessionId === 'string' && sessionId.length > 0;
1021
+ return (!!this.config.openapi?.cookieSession && typeof sessionId === 'string' && sessionId.length > 0);
600
1022
  }
601
1023
  // Lazily create a per-session cookie jar, seeded with the static apiKey
602
1024
  // cookie (if any) so it applies to every request and can be overridden by a