@samanhappy/mcphub 1.0.32 → 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 (115) hide show
  1. package/README.md +11 -1
  2. package/README.zh.md +12 -2
  3. package/dist/clients/openapi.js +439 -72
  4. package/dist/clients/openapi.js.map +1 -1
  5. package/dist/config/index.js +24 -2
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/constants/oauthServerDefaults.js +5 -0
  8. package/dist/constants/oauthServerDefaults.js.map +1 -1
  9. package/dist/controllers/mcpbController.js +6 -13
  10. package/dist/controllers/mcpbController.js.map +1 -1
  11. package/dist/controllers/oauthCallbackController.js +26 -0
  12. package/dist/controllers/oauthCallbackController.js.map +1 -1
  13. package/dist/controllers/oauthServerController.js +40 -4
  14. package/dist/controllers/oauthServerController.js.map +1 -1
  15. package/dist/controllers/serverController.js +108 -31
  16. package/dist/controllers/serverController.js.map +1 -1
  17. package/dist/controllers/templateController.js +1 -1
  18. package/dist/controllers/templateController.js.map +1 -1
  19. package/dist/routes/index.js +2 -1
  20. package/dist/routes/index.js.map +1 -1
  21. package/dist/services/authorizationService.js +28 -0
  22. package/dist/services/authorizationService.js.map +1 -0
  23. package/dist/services/cimdClientService.js +195 -0
  24. package/dist/services/cimdClientService.js.map +1 -0
  25. package/dist/services/mcpService.js +108 -11
  26. package/dist/services/mcpService.js.map +1 -1
  27. package/dist/services/oauthServerService.js +17 -1
  28. package/dist/services/oauthServerService.js.map +1 -1
  29. package/dist/services/openApiToolStatsService.js +37 -0
  30. package/dist/services/openApiToolStatsService.js.map +1 -0
  31. package/dist/services/serverConfigPresenter.js +85 -0
  32. package/dist/services/serverConfigPresenter.js.map +1 -0
  33. package/dist/services/templateService.js +48 -11
  34. package/dist/services/templateService.js.map +1 -1
  35. package/dist/utils/oauthIssuer.js +57 -0
  36. package/dist/utils/oauthIssuer.js.map +1 -0
  37. package/dist/utils/openApiRequestBody.js +220 -0
  38. package/dist/utils/openApiRequestBody.js.map +1 -0
  39. package/dist/utils/processTree.js +47 -0
  40. package/dist/utils/processTree.js.map +1 -0
  41. package/dist/utils/serverConfigPersistence.js +3 -0
  42. package/dist/utils/serverConfigPersistence.js.map +1 -1
  43. package/dist/utils/serverConfigValidation.js +13 -0
  44. package/dist/utils/serverConfigValidation.js.map +1 -0
  45. package/dist/utils/serverNameValidation.js +56 -0
  46. package/dist/utils/serverNameValidation.js.map +1 -0
  47. package/dist/utils/ssrf.js +4 -4
  48. package/dist/utils/ssrf.js.map +1 -1
  49. package/dist/utils/systemConfigCache.js +40 -2
  50. package/dist/utils/systemConfigCache.js.map +1 -1
  51. package/frontend/dist/assets/{ActivityPage-C6m1hGLq.js → ActivityPage-Du9Nh-Jy.js} +2 -2
  52. package/frontend/dist/assets/{ActivityPage-C6m1hGLq.js.map → ActivityPage-Du9Nh-Jy.js.map} +1 -1
  53. package/frontend/dist/assets/ConfirmDialog-3APts2Y9.js +2 -0
  54. package/frontend/dist/assets/ConfirmDialog-3APts2Y9.js.map +1 -0
  55. package/frontend/dist/assets/Dashboard-J4G-AZkI.js +2 -0
  56. package/frontend/dist/assets/{Dashboard-DFKfjPff.js.map → Dashboard-J4G-AZkI.js.map} +1 -1
  57. package/frontend/dist/assets/{EndpointCopy-CP_Fb1To.js → EndpointCopy-DFeLcU9A.js} +2 -2
  58. package/frontend/dist/assets/{EndpointCopy-CP_Fb1To.js.map → EndpointCopy-DFeLcU9A.js.map} +1 -1
  59. package/frontend/dist/assets/GroupsPage-CGMZBiXd.js +33 -0
  60. package/frontend/dist/assets/{GroupsPage-Cwfm5q-a.js.map → GroupsPage-CGMZBiXd.js.map} +1 -1
  61. package/frontend/dist/assets/{LoginPage-CnF44h_n.js → LoginPage-g5pJLBk0.js} +2 -2
  62. package/frontend/dist/assets/{LoginPage-CnF44h_n.js.map → LoginPage-g5pJLBk0.js.map} +1 -1
  63. package/frontend/dist/assets/{LogsPage-BFIpICsM.js → LogsPage-Bkv7xhNf.js} +2 -2
  64. package/frontend/dist/assets/{LogsPage-BFIpICsM.js.map → LogsPage-Bkv7xhNf.js.map} +1 -1
  65. package/frontend/dist/assets/MarketPage-WkLrckIr.js +3 -0
  66. package/frontend/dist/assets/MarketPage-WkLrckIr.js.map +1 -0
  67. package/frontend/dist/assets/{OAuthConsentPage-zqXUvKMF.js → OAuthConsentPage-CA35XoBK.js} +2 -2
  68. package/frontend/dist/assets/{OAuthConsentPage-zqXUvKMF.js.map → OAuthConsentPage-CA35XoBK.js.map} +1 -1
  69. package/frontend/dist/assets/{PromptsPage-Cm8ykiI4.js → PromptsPage-BuDdW69R.js} +2 -2
  70. package/frontend/dist/assets/{PromptsPage-Cm8ykiI4.js.map → PromptsPage-BuDdW69R.js.map} +1 -1
  71. package/frontend/dist/assets/{ResourcesPage-UMyO6mQ4.js → ResourcesPage-Cu-eD5e2.js} +2 -2
  72. package/frontend/dist/assets/{ResourcesPage-UMyO6mQ4.js.map → ResourcesPage-Cu-eD5e2.js.map} +1 -1
  73. package/frontend/dist/assets/{ServersPage-B0RoM8ez.js → ServersPage-B1Np5x3N.js} +5 -5
  74. package/frontend/dist/assets/{ServersPage-B0RoM8ez.js.map → ServersPage-B1Np5x3N.js.map} +1 -1
  75. package/frontend/dist/assets/{SettingsPage-BTB-36-n.js → SettingsPage-BQN8-ZQh.js} +4 -4
  76. package/frontend/dist/assets/SettingsPage-BQN8-ZQh.js.map +1 -0
  77. package/frontend/dist/assets/{StatusDot-BAMXeT2x.js → StatusDot-WyvgqplP.js} +2 -2
  78. package/frontend/dist/assets/{StatusDot-BAMXeT2x.js.map → StatusDot-WyvgqplP.js.map} +1 -1
  79. package/frontend/dist/assets/{ToggleGroup-Cfgt5lJo.js → ToggleGroup-CmeedsZU.js} +2 -2
  80. package/frontend/dist/assets/{ToggleGroup-Cfgt5lJo.js.map → ToggleGroup-CmeedsZU.js.map} +1 -1
  81. package/frontend/dist/assets/UsersPage-DqDFrIIW.js +2 -0
  82. package/frontend/dist/assets/UsersPage-DqDFrIIW.js.map +1 -0
  83. package/frontend/dist/assets/contextCost-CilWqGWT.js +2 -0
  84. package/frontend/dist/assets/contextCost-CilWqGWT.js.map +1 -0
  85. package/frontend/dist/assets/index-BvU2-VT8.js +3 -0
  86. package/frontend/dist/assets/{index-CvXw771N.js.map → index-BvU2-VT8.js.map} +1 -1
  87. package/frontend/dist/assets/index-DtrCVU1h.css +1 -0
  88. package/frontend/dist/assets/passwordValidation-DTLWgUy9.js +2 -0
  89. package/frontend/dist/assets/passwordValidation-DTLWgUy9.js.map +1 -0
  90. package/frontend/dist/assets/{resourceService-D2QmlFby.js → resourceService-BFH4w47h.js} +2 -2
  91. package/frontend/dist/assets/{resourceService-D2QmlFby.js.map → resourceService-BFH4w47h.js.map} +1 -1
  92. package/frontend/dist/assets/useCostData-CoM1SxdS.js +2 -0
  93. package/frontend/dist/assets/useCostData-CoM1SxdS.js.map +1 -0
  94. package/frontend/dist/assets/useSettingsData-DKrECrG1.js +2 -0
  95. package/frontend/dist/assets/{useSettingsData-CeCvZ2nU.js.map → useSettingsData-DKrECrG1.js.map} +1 -1
  96. package/frontend/dist/assets/variableDetection-CIrYDjrq.js +16 -0
  97. package/frontend/dist/assets/variableDetection-CIrYDjrq.js.map +1 -0
  98. package/frontend/dist/index.html +2 -2
  99. package/package.json +1 -1
  100. package/frontend/dist/assets/ConfirmDialog-BCMNzi21.js +0 -2
  101. package/frontend/dist/assets/ConfirmDialog-BCMNzi21.js.map +0 -1
  102. package/frontend/dist/assets/Dashboard-DFKfjPff.js +0 -2
  103. package/frontend/dist/assets/GroupsPage-Cwfm5q-a.js +0 -33
  104. package/frontend/dist/assets/MarketPage-Du-NqiEl.js +0 -3
  105. package/frontend/dist/assets/MarketPage-Du-NqiEl.js.map +0 -1
  106. package/frontend/dist/assets/SettingsPage-BTB-36-n.js.map +0 -1
  107. package/frontend/dist/assets/UsersPage-DlzYf7Su.js +0 -2
  108. package/frontend/dist/assets/UsersPage-DlzYf7Su.js.map +0 -1
  109. package/frontend/dist/assets/contextCost-kp2vZYp7.js +0 -2
  110. package/frontend/dist/assets/contextCost-kp2vZYp7.js.map +0 -1
  111. package/frontend/dist/assets/index-CGvImZ3F.css +0 -1
  112. package/frontend/dist/assets/index-CvXw771N.js +0 -3
  113. package/frontend/dist/assets/useSettingsData-CeCvZ2nU.js +0 -2
  114. package/frontend/dist/assets/variableDetection-DKPiHkxr.js +0 -16
  115. package/frontend/dist/assets/variableDetection-DKPiHkxr.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import axios from 'axios';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { CookieJar } from 'tough-cookie';
4
5
  import SwaggerParser from '@apidevtools/swagger-parser';
@@ -6,6 +7,51 @@ import * as yaml from 'js-yaml';
6
7
  import { assertSafeUrl, UnsafeUrlError, createRedirectValidatingFetch } from '../utils/ssrf.js';
7
8
  import { getUserDao } from '../dao/index.js';
8
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
+ }
9
55
  export class OpenAPIClient {
10
56
  constructor(config, options = {}) {
11
57
  this.config = config;
@@ -21,20 +67,47 @@ export class OpenAPIClient {
21
67
  if (!config.openapi?.url && !config.openapi?.schema) {
22
68
  throw new Error('OpenAPI URL or schema is required');
23
69
  }
24
- // 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.
25
76
  this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : '';
26
77
  this.securityConfig = config.openapi.security;
27
78
  this.persistOAuth2Token = options.persistOAuth2Token;
28
- this.httpClient = axios.create({
29
- 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({
30
101
  timeout: config.options?.timeout || 30000,
31
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 },
32
106
  headers: {
33
107
  'Content-Type': 'application/json',
34
108
  ...config.headers,
35
109
  },
36
110
  });
37
- this.setupSecurity();
38
111
  }
39
112
  extractBaseUrl(specUrl) {
40
113
  try {
@@ -46,57 +119,68 @@ export class OpenAPIClient {
46
119
  return '';
47
120
  }
48
121
  }
49
- setupSecurity() {
50
- if (!this.securityConfig || this.securityConfig.type === 'none') {
51
- 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 {};
52
130
  }
53
- switch (this.securityConfig.type) {
54
- case 'apiKey':
55
- if (this.securityConfig.apiKey) {
56
- const { name, in: location, value } = this.securityConfig.apiKey;
57
- if (location === 'header') {
58
- 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;
59
140
  }
60
- else if (location === 'query') {
61
- this.httpClient.interceptors.request.use((config) => {
141
+ else if (location === 'query' && value) {
142
+ client.interceptors.request.use((config) => {
62
143
  config.params = { ...config.params, [name]: value };
63
144
  return config;
64
145
  });
65
146
  }
66
- else if (location === 'cookie') {
67
- this.staticCookieHeader = `${name}=${value}`;
147
+ else if (location === 'cookie' && value) {
148
+ return { staticCookieHeader: `${name}=${value}` };
68
149
  }
69
150
  }
70
- break;
71
- case 'http':
72
- if (this.securityConfig.http) {
73
- const { scheme, credentials } = this.securityConfig.http;
151
+ return {};
152
+ }
153
+ case 'http': {
154
+ if (securityConfig.http) {
155
+ const { scheme, credentials } = securityConfig.http;
74
156
  if (scheme === 'bearer' && credentials) {
75
- this.httpClient.defaults.headers.common['Authorization'] = `Bearer ${credentials}`;
157
+ client.defaults.headers.common['Authorization'] = `Bearer ${credentials}`;
76
158
  }
77
159
  else if (scheme === 'basic' && credentials) {
78
- 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
+ }
79
166
  }
80
167
  }
81
- break;
168
+ return {};
169
+ }
82
170
  case 'oauth2':
83
- if (this.securityConfig.oauth2?.token) {
84
- this.setAuthorizationHeader(this.securityConfig.oauth2.token);
85
- }
86
- break;
171
+ this.setAuthorizationHeader(client, securityConfig.oauth2?.token);
172
+ return {};
87
173
  case 'openIdConnect':
88
- if (this.securityConfig.openIdConnect?.token) {
89
- this.setAuthorizationHeader(this.securityConfig.openIdConnect.token);
90
- }
91
- break;
174
+ this.setAuthorizationHeader(client, securityConfig.openIdConnect?.token);
175
+ return {};
92
176
  }
93
177
  }
94
- setAuthorizationHeader(token) {
178
+ setAuthorizationHeader(client, token) {
95
179
  if (token) {
96
- this.httpClient.defaults.headers.common['Authorization'] = 'Bearer ' + token;
180
+ client.defaults.headers.common['Authorization'] = 'Bearer ' + token;
97
181
  return;
98
182
  }
99
- delete this.httpClient.defaults.headers.common['Authorization'];
183
+ delete client.defaults.headers.common['Authorization'];
100
184
  }
101
185
  getOAuth2Config() {
102
186
  return this.securityConfig?.type === 'oauth2' ? this.securityConfig.oauth2 : undefined;
@@ -115,7 +199,7 @@ export class OpenAPIClient {
115
199
  if (this.config.openapi?.security?.oauth2) {
116
200
  this.config.openapi.security.oauth2 = oauth2;
117
201
  }
118
- this.setAuthorizationHeader(undefined);
202
+ this.setAuthorizationHeader(this.httpClient, undefined);
119
203
  await this.persistOAuth2Token?.({ ...oauth2 });
120
204
  return true;
121
205
  }
@@ -143,7 +227,7 @@ export class OpenAPIClient {
143
227
  if (this.config.openapi?.security?.oauth2) {
144
228
  this.config.openapi.security.oauth2 = oauth2;
145
229
  }
146
- this.setAuthorizationHeader(token);
230
+ this.setAuthorizationHeader(this.httpClient, token);
147
231
  await this.persistOAuth2Token?.(oauth2);
148
232
  }
149
233
  async fetchOAuth2ClientCredentialsToken(oauth2) {
@@ -160,9 +244,15 @@ export class OpenAPIClient {
160
244
  if (Array.isArray(oauth2.scopes) && oauth2.scopes.length > 0) {
161
245
  body.set('scope', oauth2.scopes.join(' '));
162
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
+ });
163
253
  const response = await this.httpClient.request({
164
254
  method: 'post',
165
- url: oauth2.tokenUrl,
255
+ url: safeTokenUrl,
166
256
  baseURL: undefined,
167
257
  headers: {
168
258
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -185,12 +275,12 @@ export class OpenAPIClient {
185
275
  return undefined;
186
276
  }
187
277
  if (this.hasValidOAuth2Token(oauth2)) {
188
- this.setAuthorizationHeader(oauth2.token);
278
+ this.setAuthorizationHeader(this.httpClient, oauth2.token);
189
279
  return oauth2.token;
190
280
  }
191
281
  if (!oauth2.tokenUrl || !oauth2.clientId) {
192
282
  if (oauth2.token) {
193
- this.setAuthorizationHeader(oauth2.token);
283
+ this.setAuthorizationHeader(this.httpClient, oauth2.token);
194
284
  }
195
285
  return oauth2.token;
196
286
  }
@@ -220,27 +310,37 @@ export class OpenAPIClient {
220
310
  this.refGuardRejection = undefined;
221
311
  // Parse and dereference the OpenAPI specification
222
312
  if (this.config.openapi?.url) {
223
- // Fetch the document through the authenticated httpClient (carries
224
- // config.headers + security credentials from setupSecurity, and uses
225
- // maxRedirects: 0 so credentials are never forwarded across a
226
- // cross-origin redirect). SwaggerParser's own resolver is bypassed for
227
- // the main document so its (unauthenticated) headers never see the
228
- // credentials; external $ref resolution still uses that resolver and
229
- // 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.
230
317
  const specUrl = this.config.openapi.url;
231
- await assertSafeUrl(specUrl, { allowInternal: this.allowInternalNetworks });
318
+ const safeSpecUrl = await assertSafeUrl(specUrl, {
319
+ allowInternal: this.allowInternalNetworks,
320
+ });
232
321
  const requestConfig = {
233
322
  responseType: 'text',
234
323
  transformResponse: [(data) => data],
235
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;
236
336
  // The static apiKey.in:'cookie' value is otherwise only injected in
237
- // callTool; apply it here too so cookie-protected spec URLs load.
238
- if (this.staticCookieHeader) {
239
- requestConfig.headers = { Cookie: this.staticCookieHeader };
337
+ // callTool; apply it here so cookie-protected spec URLs load.
338
+ if (specCookie) {
339
+ requestConfig.headers = { Cookie: specCookie };
240
340
  }
241
- const response = await this.httpClient.get(specUrl, requestConfig);
341
+ const response = await specClient.get(safeSpecUrl, requestConfig);
242
342
  const raw = typeof response.data === 'string' ? response.data : String(response.data);
243
- this.spec = (await SwaggerParser.dereference(specUrl, this.parseSpecDocument(raw), this.guardedRefResolveOptions()));
343
+ this.spec = (await SwaggerParser.dereference(safeSpecUrl, this.parseSpecDocument(raw), this.guardedRefResolveOptions()));
244
344
  }
245
345
  else if (this.config.openapi?.schema) {
246
346
  // For schema object, we need to pass it as a cloned object
@@ -298,18 +398,22 @@ export class OpenAPIClient {
298
398
  order: 1,
299
399
  canRead: (file) => /^https?:\/\//i.test(file.url),
300
400
  read: async (file) => {
401
+ let safeRefUrl;
301
402
  try {
302
- await assertSafeUrl(file.url, { allowInternal: this.allowInternalNetworks });
403
+ safeRefUrl = await assertSafeUrl(file.url, {
404
+ allowInternal: this.allowInternalNetworks,
405
+ });
303
406
  }
304
407
  catch (error) {
305
408
  rejectUnsafe(error);
409
+ return '';
306
410
  }
307
411
  // Plain fetch, NOT this.httpClient: default auth headers and
308
412
  // interceptors must never be forwarded cross-origin to $ref
309
413
  // targets (#1044). Redirects are followed manually with every hop
310
414
  // re-validated by the guard.
311
415
  const safeFetch = createRedirectValidatingFetch((url, init) => fetch(url, init), this.allowInternalNetworks);
312
- const response = await safeFetch(file.url, { method: 'GET', headers: {} });
416
+ const response = await safeFetch(safeRefUrl, { method: 'GET', headers: {} });
313
417
  const buf = await response.arrayBuffer();
314
418
  return new TextDecoder().decode(buf);
315
419
  },
@@ -378,8 +482,6 @@ export class OpenAPIClient {
378
482
  this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
379
483
  }
380
484
  }
381
- // Update HTTP client's baseURL
382
- this.httpClient.defaults.baseURL = this.baseUrl;
383
485
  }
384
486
  extractTools() {
385
487
  if (!this.spec?.paths) {
@@ -421,15 +523,30 @@ export class OpenAPIClient {
421
523
  operationName = uniqueName;
422
524
  }
423
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
+ }
424
541
  const tool = {
425
542
  name: operationName,
426
- description: operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
543
+ description,
427
544
  // SwaggerParser.dereference turns recursive $ref schemas into live
428
545
  // circular references on the dereferenced spec objects. generateInputSchema
429
546
  // references those objects directly, so without sanitization every
430
547
  // downstream serializer (tokenCost, getServerConfig, MCP ListTools,
431
548
  // embeddings) throws "Converting circular structure to JSON". See #959.
432
- inputSchema: createSafeJSON(this.generateInputSchema(operation, path, method)),
549
+ inputSchema: createSafeJSON(this.generateInputSchema(operation, path, method, requestBodySelection)),
433
550
  operationId: operation.operationId || operationName,
434
551
  method: method,
435
552
  path,
@@ -441,7 +558,7 @@ export class OpenAPIClient {
441
558
  }
442
559
  }
443
560
  }
444
- generateInputSchema(operation, _path, _method) {
561
+ generateInputSchema(operation, _path, _method, requestBodySelection) {
445
562
  const schema = {
446
563
  type: 'object',
447
564
  properties: {},
@@ -482,12 +599,18 @@ export class OpenAPIClient {
482
599
  }
483
600
  }
484
601
  }
485
- // Handle request body
486
- 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) {
487
607
  const requestBody = operation.requestBody;
488
- const jsonContent = requestBody.content?.['application/json'];
489
- if (jsonContent?.schema) {
490
- 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;
491
614
  if (requestBody.required) {
492
615
  required.push('body');
493
616
  }
@@ -523,9 +646,14 @@ export class OpenAPIClient {
523
646
  const pathParams = tool.parameters?.filter((p) => p.in === 'path') || [];
524
647
  for (const param of pathParams) {
525
648
  const value = args[param.name];
526
- if (value !== undefined) {
527
- 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`);
528
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));
529
657
  }
530
658
  // Build query parameters
531
659
  const queryParams = {};
@@ -542,9 +670,41 @@ export class OpenAPIClient {
542
670
  url,
543
671
  params: queryParams,
544
672
  };
545
- // Add request body if applicable
546
- if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
547
- 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
+ }
548
708
  }
549
709
  // Collect all headers to be sent
550
710
  const allHeaders = {};
@@ -564,6 +724,11 @@ export class OpenAPIClient {
564
724
  }
565
725
  }
566
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
+ }
567
732
  // Set headers if any were collected
568
733
  if (Object.keys(allHeaders).length > 0) {
569
734
  requestConfig.headers = allHeaders;
@@ -574,15 +739,34 @@ export class OpenAPIClient {
574
739
  // final URL rather than trusting either alone.
575
740
  resolvedTarget = null;
576
741
  try {
577
- 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);
578
759
  }
579
760
  catch {
580
761
  // relative path with no base — no host to validate; axios surfaces the error
581
762
  }
582
763
  if (resolvedTarget) {
583
- await assertSafeUrl(resolvedTarget.href, {
764
+ const safeTargetUrl = await assertSafeUrl(resolvedTarget.href, {
584
765
  allowInternal: this.allowInternalNetworks,
585
766
  });
767
+ const safeTarget = new URL(safeTargetUrl);
768
+ requestConfig.baseURL = safeTarget.origin;
769
+ requestConfig.url = `${safeTarget.pathname}${safeTarget.search}`;
586
770
  }
587
771
  const cookieSessionEnabled = this.isCookieSessionEnabled(sessionId);
588
772
  // Inject cookies when either the dynamic session store is active for this
@@ -650,6 +834,189 @@ export class OpenAPIClient {
650
834
  getSpec() {
651
835
  return this.spec;
652
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
+ }
653
1020
  isCookieSessionEnabled(sessionId) {
654
1021
  return (!!this.config.openapi?.cookieSession && typeof sessionId === 'string' && sessionId.length > 0);
655
1022
  }