mcp4openapi 0.3.0 → 0.3.1

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 (188) hide show
  1. package/dist/src/argument-normalizer.d.ts +5 -0
  2. package/dist/src/argument-normalizer.d.ts.map +1 -0
  3. package/dist/src/argument-normalizer.js +61 -0
  4. package/dist/src/argument-normalizer.js.map +1 -0
  5. package/dist/src/auth/oauth-provider.d.ts.map +1 -1
  6. package/dist/src/auth/oauth-provider.js +5 -2
  7. package/dist/src/auth/oauth-provider.js.map +1 -1
  8. package/dist/src/cli-config.d.ts +9 -0
  9. package/dist/src/cli-config.d.ts.map +1 -0
  10. package/dist/src/cli-config.js +111 -0
  11. package/dist/src/cli-config.js.map +1 -0
  12. package/dist/src/composite-executor.d.ts +77 -0
  13. package/dist/src/composite-executor.d.ts.map +1 -0
  14. package/dist/src/composite-executor.js +193 -0
  15. package/dist/src/composite-executor.js.map +1 -0
  16. package/dist/src/constants.d.ts +85 -0
  17. package/dist/src/constants.d.ts.map +1 -0
  18. package/dist/src/constants.js +85 -0
  19. package/dist/src/constants.js.map +1 -0
  20. package/dist/src/core/cli-config.d.ts.map +1 -1
  21. package/dist/src/core/cli-config.js +1 -0
  22. package/dist/src/core/cli-config.js.map +1 -1
  23. package/dist/src/core/index.d.ts.map +1 -1
  24. package/dist/src/core/index.js +1 -0
  25. package/dist/src/core/index.js.map +1 -1
  26. package/dist/src/dag-executor.d.ts +49 -0
  27. package/dist/src/dag-executor.d.ts.map +1 -0
  28. package/dist/src/dag-executor.js +138 -0
  29. package/dist/src/dag-executor.js.map +1 -0
  30. package/dist/src/errors.d.ts +59 -0
  31. package/dist/src/errors.d.ts.map +1 -0
  32. package/dist/src/errors.js +119 -0
  33. package/dist/src/errors.js.map +1 -0
  34. package/dist/src/filtering.d.ts +19 -0
  35. package/dist/src/filtering.d.ts.map +1 -0
  36. package/dist/src/filtering.js +292 -0
  37. package/dist/src/filtering.js.map +1 -0
  38. package/dist/src/generated-schemas.d.ts +45 -0
  39. package/dist/src/generated-schemas.d.ts.map +1 -1
  40. package/dist/src/generated-schemas.js +3 -0
  41. package/dist/src/generated-schemas.js.map +1 -1
  42. package/dist/src/http-client-factory.d.ts +62 -0
  43. package/dist/src/http-client-factory.d.ts.map +1 -0
  44. package/dist/src/http-client-factory.js +133 -0
  45. package/dist/src/http-client-factory.js.map +1 -0
  46. package/dist/src/http-transport-config.d.ts +6 -0
  47. package/dist/src/http-transport-config.d.ts.map +1 -0
  48. package/dist/src/http-transport-config.js +47 -0
  49. package/dist/src/http-transport-config.js.map +1 -0
  50. package/dist/src/http-transport.d.ts +316 -0
  51. package/dist/src/http-transport.d.ts.map +1 -0
  52. package/dist/src/http-transport.js +2412 -0
  53. package/dist/src/http-transport.js.map +1 -0
  54. package/dist/src/index.js +0 -0
  55. package/dist/src/interceptors.d.ts +116 -0
  56. package/dist/src/interceptors.d.ts.map +1 -0
  57. package/dist/src/interceptors.js +392 -0
  58. package/dist/src/interceptors.js.map +1 -0
  59. package/dist/src/jsonrpc-validator.d.ts +27 -0
  60. package/dist/src/jsonrpc-validator.d.ts.map +1 -0
  61. package/dist/src/jsonrpc-validator.js +58 -0
  62. package/dist/src/jsonrpc-validator.js.map +1 -0
  63. package/dist/src/logger.d.ts +59 -0
  64. package/dist/src/logger.d.ts.map +1 -0
  65. package/dist/src/logger.js +177 -0
  66. package/dist/src/logger.js.map +1 -0
  67. package/dist/src/mcp-server-manager.d.ts +20 -0
  68. package/dist/src/mcp-server-manager.d.ts.map +1 -0
  69. package/dist/src/mcp-server-manager.js +38 -0
  70. package/dist/src/mcp-server-manager.js.map +1 -0
  71. package/dist/src/mcp-server.d.ts +203 -0
  72. package/dist/src/mcp-server.d.ts.map +1 -0
  73. package/dist/src/mcp-server.js +1369 -0
  74. package/dist/src/mcp-server.js.map +1 -0
  75. package/dist/src/metrics.d.ts +97 -0
  76. package/dist/src/metrics.d.ts.map +1 -0
  77. package/dist/src/metrics.js +273 -0
  78. package/dist/src/metrics.js.map +1 -0
  79. package/dist/src/naming-warnings.d.ts +23 -0
  80. package/dist/src/naming-warnings.d.ts.map +1 -0
  81. package/dist/src/naming-warnings.js +83 -0
  82. package/dist/src/naming-warnings.js.map +1 -0
  83. package/dist/src/naming.d.ts +58 -0
  84. package/dist/src/naming.d.ts.map +1 -0
  85. package/dist/src/naming.js +510 -0
  86. package/dist/src/naming.js.map +1 -0
  87. package/dist/src/oauth-provider.d.ts +131 -0
  88. package/dist/src/oauth-provider.d.ts.map +1 -0
  89. package/dist/src/oauth-provider.js +836 -0
  90. package/dist/src/oauth-provider.js.map +1 -0
  91. package/dist/src/openapi/openapi-parser.d.ts.map +1 -1
  92. package/dist/src/openapi/openapi-parser.js +22 -0
  93. package/dist/src/openapi/openapi-parser.js.map +1 -1
  94. package/dist/src/openapi-parser.d.ts +70 -0
  95. package/dist/src/openapi-parser.d.ts.map +1 -0
  96. package/dist/src/openapi-parser.js +436 -0
  97. package/dist/src/openapi-parser.js.map +1 -0
  98. package/dist/src/profile/profile-loader.d.ts.map +1 -1
  99. package/dist/src/profile/profile-loader.js +8 -1
  100. package/dist/src/profile/profile-loader.js.map +1 -1
  101. package/dist/src/profile/profile-registry.d.ts +2 -1
  102. package/dist/src/profile/profile-registry.d.ts.map +1 -1
  103. package/dist/src/profile/profile-registry.js +18 -1
  104. package/dist/src/profile/profile-registry.js.map +1 -1
  105. package/dist/src/profile/profile-resolver.d.ts +16 -0
  106. package/dist/src/profile/profile-resolver.d.ts.map +1 -1
  107. package/dist/src/profile/profile-resolver.js +120 -0
  108. package/dist/src/profile/profile-resolver.js.map +1 -1
  109. package/dist/src/profile-loader.d.ts +78 -0
  110. package/dist/src/profile-loader.d.ts.map +1 -0
  111. package/dist/src/profile-loader.js +483 -0
  112. package/dist/src/profile-loader.js.map +1 -0
  113. package/dist/src/profile-registry.d.ts +18 -0
  114. package/dist/src/profile-registry.d.ts.map +1 -0
  115. package/dist/src/profile-registry.js +26 -0
  116. package/dist/src/profile-registry.js.map +1 -0
  117. package/dist/src/profile-resolver.d.ts +19 -0
  118. package/dist/src/profile-resolver.d.ts.map +1 -0
  119. package/dist/src/profile-resolver.js +167 -0
  120. package/dist/src/profile-resolver.js.map +1 -0
  121. package/dist/src/proxy-executor.d.ts +86 -0
  122. package/dist/src/proxy-executor.d.ts.map +1 -0
  123. package/dist/src/proxy-executor.js +497 -0
  124. package/dist/src/proxy-executor.js.map +1 -0
  125. package/dist/src/schema-validator.d.ts +30 -0
  126. package/dist/src/schema-validator.d.ts.map +1 -0
  127. package/dist/src/schema-validator.js +128 -0
  128. package/dist/src/schema-validator.js.map +1 -0
  129. package/dist/src/startup-profile.d.ts +17 -0
  130. package/dist/src/startup-profile.d.ts.map +1 -0
  131. package/dist/src/startup-profile.js +30 -0
  132. package/dist/src/startup-profile.js.map +1 -0
  133. package/dist/src/startup-validation.d.ts +11 -0
  134. package/dist/src/startup-validation.d.ts.map +1 -0
  135. package/dist/src/startup-validation.js +21 -0
  136. package/dist/src/startup-validation.js.map +1 -0
  137. package/dist/src/tool-filter.d.ts +65 -0
  138. package/dist/src/tool-filter.d.ts.map +1 -0
  139. package/dist/src/tool-filter.js +471 -0
  140. package/dist/src/tool-filter.js.map +1 -0
  141. package/dist/src/tool-generator.d.ts +67 -0
  142. package/dist/src/tool-generator.d.ts.map +1 -0
  143. package/dist/src/tool-generator.js +182 -0
  144. package/dist/src/tool-generator.js.map +1 -0
  145. package/dist/src/tooling/composite-executor.d.ts.map +1 -1
  146. package/dist/src/tooling/composite-executor.js +7 -2
  147. package/dist/src/tooling/composite-executor.js.map +1 -1
  148. package/dist/src/tooling/proxy-executor.d.ts.map +1 -1
  149. package/dist/src/tooling/proxy-executor.js +4 -0
  150. package/dist/src/tooling/proxy-executor.js.map +1 -1
  151. package/dist/src/tooling/tool-generator.d.ts.map +1 -1
  152. package/dist/src/tooling/tool-generator.js +36 -3
  153. package/dist/src/tooling/tool-generator.js.map +1 -1
  154. package/dist/src/transport/http-transport-config.d.ts.map +1 -1
  155. package/dist/src/transport/http-transport-config.js +1 -0
  156. package/dist/src/transport/http-transport-config.js.map +1 -1
  157. package/dist/src/transport/http-transport.d.ts +5 -0
  158. package/dist/src/transport/http-transport.d.ts.map +1 -1
  159. package/dist/src/transport/http-transport.js +63 -1
  160. package/dist/src/transport/http-transport.js.map +1 -1
  161. package/dist/src/transport/profile-index.d.ts +84 -0
  162. package/dist/src/transport/profile-index.d.ts.map +1 -0
  163. package/dist/src/transport/profile-index.js +405 -0
  164. package/dist/src/transport/profile-index.js.map +1 -0
  165. package/dist/src/types/http-transport.d.ts +1 -0
  166. package/dist/src/types/http-transport.d.ts.map +1 -1
  167. package/dist/src/types/openapi.d.ts +3 -0
  168. package/dist/src/types/openapi.d.ts.map +1 -1
  169. package/dist/src/types/profile.d.ts +3 -0
  170. package/dist/src/types/profile.d.ts.map +1 -1
  171. package/dist/src/validation/validation-utils.d.ts.map +1 -1
  172. package/dist/src/validation/validation-utils.js +1 -0
  173. package/dist/src/validation/validation-utils.js.map +1 -1
  174. package/dist/src/validation-utils.d.ts +49 -0
  175. package/dist/src/validation-utils.d.ts.map +1 -0
  176. package/dist/src/validation-utils.js +138 -0
  177. package/dist/src/validation-utils.js.map +1 -0
  178. package/html/profile-index.html +386 -0
  179. package/package.json +2 -1
  180. package/profile-schema.json +14 -0
  181. package/profiles/gitlab/developer-profile-oauth.json +1 -1
  182. package/profiles/gitlab/developer-profile.json +1508 -0
  183. package/profiles/gitlab/developer-profile.test.json +3432 -0
  184. package/profiles/n8n/profile-optimized.json +1 -1
  185. package/profiles/n8n/profile.json +1 -1
  186. package/profiles/n8n-nodes/profile-nodes.json +1 -1
  187. package/profiles/semgrep/profile.json +1 -1
  188. package/profiles/youtrack/profile.json +1 -1
@@ -0,0 +1,836 @@
1
+ /**
2
+ * OAuth 2.0 Provider Adapter
3
+ *
4
+ * Implements MCP SDK OAuthServerProvider interface to integrate with external
5
+ * OAuth 2.0 authorization servers (e.g., GitLab, GitHub, etc.)
6
+ *
7
+ * Architecture:
8
+ * - This server acts as an OAuth client to the external provider (Proxy/Gateway)
9
+ * - Implements "Callback Mode":
10
+ * 1. Client -> MCP (Authorize) -> MCP redirects to Provider (with MCP callback URL)
11
+ * 2. Provider -> MCP (Callback) -> MCP exchanges code for tokens
12
+ * 3. MCP redirects to Client (with Internal Code)
13
+ * 4. Client -> MCP (Token) -> MCP returns stored tokens
14
+ */
15
+ import { randomUUID, createHash } from 'node:crypto';
16
+ import { isIP } from 'node:net';
17
+ import { OAUTH_PATHS, OAUTH_RATE_LIMIT } from './constants.js';
18
+ import { escapeHtmlSafe } from './validation-utils.js';
19
+ /**
20
+ * In-memory store for OAuth client registrations
21
+ */
22
+ export class InMemoryClientsStore {
23
+ constructor() {
24
+ this.clients = new Map();
25
+ }
26
+ async getClient(clientId) {
27
+ return this.clients.get(clientId);
28
+ }
29
+ async registerClient(clientMetadata) {
30
+ this.clients.set(clientMetadata.client_id, clientMetadata);
31
+ return clientMetadata;
32
+ }
33
+ }
34
+ /**
35
+ * OAuth Provider Adapter for external OAuth servers
36
+ */
37
+ export class ExternalOAuthProvider {
38
+ constructor(config, logger) {
39
+ // In-memory storage
40
+ this.authorizationCodes = new Map();
41
+ this.accessTokens = new Map();
42
+ this.stateStore = new Map();
43
+ this.endpointsInitialized = false;
44
+ this.initializationPromise = null;
45
+ this.config = config;
46
+ this.logger = logger;
47
+ this._clientsStore = new InMemoryClientsStore();
48
+ // Resolve environment variables in OAuth config
49
+ this.config = this.resolveEnvVars(config);
50
+ // Pre-register mcp-proxy-client for VS Code compatibility
51
+ // VS Code doesn't call /oauth/register endpoint before calling /oauth/authorize
52
+ // This client has empty redirect_uris, allowing any redirect URI (validated at runtime)
53
+ const proxyClient = {
54
+ client_id: 'mcp-proxy-client',
55
+ client_secret: 'mcp-proxy-secret',
56
+ redirect_uris: [], // Empty = allow any redirect URI
57
+ grant_types: ['authorization_code', 'refresh_token'],
58
+ response_types: ['code'],
59
+ scope: this.config.scopes ? this.config.scopes.join(' ') : '',
60
+ };
61
+ this._clientsStore.registerClient(proxyClient);
62
+ this.logger.info('Pre-registered mcp-proxy-client for VS Code compatibility');
63
+ }
64
+ /**
65
+ * Lazy initialization of OAuth endpoints (async)
66
+ * Public method to allow HttpTransport to ensure initialization before client validation
67
+ */
68
+ async ensureEndpointsInitialized() {
69
+ if (this.endpointsInitialized) {
70
+ return;
71
+ }
72
+ // Prevent concurrent initializations
73
+ if (this.initializationPromise) {
74
+ return this.initializationPromise;
75
+ }
76
+ this.initializationPromise = (async () => {
77
+ // Register default client BEFORE deriving endpoints
78
+ // This ensures the client is available immediately, even if endpoint derivation fails
79
+ // The client_id from config should be registered as soon as possible to avoid
80
+ // race conditions where /oauth/authorize is called before initialization completes
81
+ if (this.config.client_id) {
82
+ // Allow localhost and configured redirect_uri for default client
83
+ const allowedUris = [];
84
+ if (this.config.redirect_uri) {
85
+ allowedUris.push(this.config.redirect_uri);
86
+ }
87
+ // Also allow common localhost patterns for development/testing
88
+ allowedUris.push('http://localhost:3003/oauth/callback');
89
+ allowedUris.push('http://127.0.0.1:3003/oauth/callback');
90
+ const defaultClient = {
91
+ client_id: this.config.client_id,
92
+ client_secret: this.config.client_secret,
93
+ redirect_uris: allowedUris,
94
+ grant_types: ['authorization_code', 'refresh_token'],
95
+ response_types: ['code'],
96
+ scope: this.config.scopes ? this.config.scopes.join(' ') : '',
97
+ };
98
+ this._clientsStore.registerClient(defaultClient);
99
+ this.logger.info('Registered default OAuth client', {
100
+ clientId: this.config.client_id,
101
+ redirectUris: allowedUris
102
+ });
103
+ }
104
+ // Derive endpoints from issuer if needed
105
+ this.config = await this.deriveEndpointsFromIssuer(this.config);
106
+ // Validate that we have required endpoints
107
+ if (!this.config.authorization_endpoint || !this.config.token_endpoint) {
108
+ throw new Error('OAuth config must provide either issuer OR both authorization_endpoint and token_endpoint');
109
+ }
110
+ this.logger.info('ExternalOAuthProvider initialized', {
111
+ authEndpoint: this.config.authorization_endpoint,
112
+ tokenEndpoint: this.config.token_endpoint,
113
+ hasClientId: !!this.config.client_id,
114
+ scopes: this.config.scopes || [],
115
+ });
116
+ this.endpointsInitialized = true;
117
+ })();
118
+ return this.initializationPromise;
119
+ }
120
+ get clientsStore() {
121
+ return this._clientsStore;
122
+ }
123
+ get authorizationEndpoint() {
124
+ // May be undefined before ensureEndpointsInitialized() is called
125
+ // when OAuth config provides issuer instead of explicit endpoints
126
+ return this.config.authorization_endpoint;
127
+ }
128
+ get redirectUri() {
129
+ return this.config.redirect_uri;
130
+ }
131
+ get scopes() {
132
+ return this.config.scopes || [];
133
+ }
134
+ /**
135
+ * Fetch OAuth Authorization Server Metadata (RFC 8414)
136
+ */
137
+ async fetchOAuthMetadata(issuerUrl) {
138
+ try {
139
+ // Use URL constructor to properly handle trailing slashes
140
+ const metadataUrl = new URL(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, issuerUrl).toString();
141
+ const response = await fetch(metadataUrl, {
142
+ headers: { 'Accept': 'application/json' },
143
+ signal: AbortSignal.timeout(5000), // 5 second timeout
144
+ });
145
+ if (!response.ok) {
146
+ return null;
147
+ }
148
+ const metadata = await response.json();
149
+ return {
150
+ authorization_endpoint: metadata.authorization_endpoint,
151
+ token_endpoint: metadata.token_endpoint,
152
+ };
153
+ }
154
+ catch (error) {
155
+ this.logger.debug('OAuth metadata fetch failed', { issuerUrl, error });
156
+ return null;
157
+ }
158
+ }
159
+ /**
160
+ * Resolve environment variable references in OAuth config
161
+ */
162
+ resolveEnvVars(config) {
163
+ const resolve = (value) => {
164
+ if (!value)
165
+ return value;
166
+ const match = value.match(/^\$\{env:([^}]+)\}$/);
167
+ if (match) {
168
+ const envVar = match[1];
169
+ const envValue = process.env[envVar];
170
+ if (!envValue) {
171
+ throw new Error(`Environment variable ${envVar} not found (referenced in OAuth config)`);
172
+ }
173
+ return envValue;
174
+ }
175
+ return value;
176
+ };
177
+ return {
178
+ ...config,
179
+ issuer: resolve(config.issuer),
180
+ authorization_endpoint: resolve(config.authorization_endpoint) || config.authorization_endpoint,
181
+ token_endpoint: resolve(config.token_endpoint) || config.token_endpoint,
182
+ client_id: resolve(config.client_id),
183
+ client_secret: resolve(config.client_secret),
184
+ redirect_uri: resolve(config.redirect_uri),
185
+ registration_endpoint: resolve(config.registration_endpoint),
186
+ introspection_endpoint: resolve(config.introspection_endpoint),
187
+ revocation_endpoint: resolve(config.revocation_endpoint),
188
+ };
189
+ }
190
+ /**
191
+ * Derive OAuth endpoints from issuer if needed
192
+ */
193
+ async deriveEndpointsFromIssuer(config) {
194
+ // If both endpoints are explicitly provided, no need to derive
195
+ if (config.authorization_endpoint && config.token_endpoint) {
196
+ return config;
197
+ }
198
+ // If issuer is not provided, we can't derive
199
+ if (!config.issuer) {
200
+ if (!config.authorization_endpoint || !config.token_endpoint) {
201
+ throw new Error('OAuth config must provide either issuer OR both authorization_endpoint and token_endpoint');
202
+ }
203
+ return config;
204
+ }
205
+ const issuer = config.issuer;
206
+ this.logger.info('Deriving OAuth endpoints from issuer', { issuer });
207
+ // Try to fetch OAuth metadata
208
+ const metadata = await this.fetchOAuthMetadata(issuer);
209
+ if (metadata) {
210
+ this.logger.info('Successfully discovered OAuth endpoints', {
211
+ authorization_endpoint: metadata.authorization_endpoint,
212
+ token_endpoint: metadata.token_endpoint,
213
+ });
214
+ return {
215
+ ...config,
216
+ authorization_endpoint: config.authorization_endpoint || metadata.authorization_endpoint,
217
+ token_endpoint: config.token_endpoint || metadata.token_endpoint,
218
+ };
219
+ }
220
+ // Fallback to standard OAuth paths
221
+ this.logger.info('OAuth metadata fetch failed, using standard OAuth paths', { issuer });
222
+ return {
223
+ ...config,
224
+ authorization_endpoint: config.authorization_endpoint || `${issuer}/oauth/authorize`,
225
+ token_endpoint: config.token_endpoint || `${issuer}/oauth/token`,
226
+ };
227
+ }
228
+ /**
229
+ * Check if redirect URI host is allowed
230
+ * Prevents open redirect vulnerabilities (CWE-601)
231
+ */
232
+ isAllowedRedirectHost(redirectUri) {
233
+ try {
234
+ const url = new URL(redirectUri);
235
+ const hostname = url.hostname;
236
+ // Default to localhost only if not configured
237
+ const allowedHosts = this.config.allowed_redirect_hosts || ['localhost', '127.0.0.1'];
238
+ for (const allowed of allowedHosts) {
239
+ if (this.matchRedirectHost(hostname, allowed)) {
240
+ return true;
241
+ }
242
+ }
243
+ return false;
244
+ }
245
+ catch {
246
+ // Invalid URL
247
+ return false;
248
+ }
249
+ }
250
+ /**
251
+ * Match hostname against allowlist entry
252
+ *
253
+ * Supports:
254
+ * - Exact hostnames
255
+ * - Wildcard subdomains (*.example.com)
256
+ * - IPv4 exact matches
257
+ * - IPv4 CIDR ranges (e.g., 10.0.0.0/8)
258
+ * - IPv6 exact matches
259
+ * - IPv6 CIDR ranges (e.g., 2001:db8::/32)
260
+ */
261
+ matchRedirectHost(hostname, pattern) {
262
+ const normalizedHost = this.stripIpv6Brackets(hostname);
263
+ const normalizedPattern = this.stripIpv6Brackets(pattern);
264
+ if (normalizedHost === normalizedPattern) {
265
+ return true;
266
+ }
267
+ if (normalizedPattern.startsWith('*.')) {
268
+ const domain = normalizedPattern.slice(2);
269
+ return normalizedHost === domain || normalizedHost.endsWith('.' + domain);
270
+ }
271
+ if (normalizedPattern.includes('/')) {
272
+ return this.matchCIDR(normalizedHost, normalizedPattern);
273
+ }
274
+ return false;
275
+ }
276
+ /**
277
+ * Check if IP address is within CIDR range
278
+ *
279
+ * Example: '192.168.1.50' matches '192.168.1.0/24'
280
+ * '2001:db8::1' matches '2001:db8::/32'
281
+ */
282
+ matchCIDR(ip, cidr) {
283
+ const [rawRange, bits] = cidr.split('/');
284
+ const range = this.stripIpv6Brackets(rawRange);
285
+ const maskBits = parseInt(bits, 10);
286
+ if (isNaN(maskBits)) {
287
+ return false;
288
+ }
289
+ const ipVersion = isIP(ip);
290
+ const rangeVersion = isIP(range);
291
+ if (ipVersion === 0 || rangeVersion === 0 || ipVersion !== rangeVersion) {
292
+ return false;
293
+ }
294
+ if (ipVersion === 4) {
295
+ if (maskBits < 0 || maskBits > 32) {
296
+ this.logger.warn('Invalid IPv4 CIDR mask bits for redirect host allowlist', { cidr });
297
+ return false;
298
+ }
299
+ const ipInt = this.ipv4ToInt(ip);
300
+ const rangeInt = this.ipv4ToInt(range);
301
+ if (ipInt === null || rangeInt === null) {
302
+ return false;
303
+ }
304
+ const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
305
+ return (ipInt & mask) === (rangeInt & mask);
306
+ }
307
+ if (maskBits < 0 || maskBits > 128) {
308
+ this.logger.warn('Invalid IPv6 CIDR mask bits for redirect host allowlist', { cidr });
309
+ return false;
310
+ }
311
+ const ipInt = this.ipv6ToBigInt(ip);
312
+ const rangeInt = this.ipv6ToBigInt(range);
313
+ if (ipInt === null || rangeInt === null) {
314
+ return false;
315
+ }
316
+ const mask = this.ipv6Mask(maskBits);
317
+ return (ipInt & mask) === (rangeInt & mask);
318
+ }
319
+ /**
320
+ * Convert IPv4 address to 32-bit integer
321
+ */
322
+ ipv4ToInt(ip) {
323
+ const parts = ip.split('.');
324
+ if (parts.length !== 4) {
325
+ return null;
326
+ }
327
+ let result = 0;
328
+ for (let i = 0; i < 4; i++) {
329
+ const octet = parseInt(parts[i], 10);
330
+ if (isNaN(octet) || octet < 0 || octet > 255) {
331
+ return null;
332
+ }
333
+ result = (result << 8) | octet;
334
+ }
335
+ return result >>> 0;
336
+ }
337
+ /**
338
+ * Convert IPv6 address to 128-bit BigInt
339
+ */
340
+ ipv6ToBigInt(ip) {
341
+ const cleaned = this.stripIpv6Brackets(ip);
342
+ // Handle IPv4-mapped IPv6 (e.g., ::ffff:192.168.0.1)
343
+ let ipv4Tail = null;
344
+ let base = cleaned;
345
+ if (cleaned.includes('.')) {
346
+ const lastColon = cleaned.lastIndexOf(':');
347
+ if (lastColon === -1)
348
+ return null;
349
+ const ipv4Part = cleaned.slice(lastColon + 1);
350
+ ipv4Tail = this.ipv4ToInt(ipv4Part);
351
+ if (ipv4Tail === null)
352
+ return null;
353
+ base = cleaned.slice(0, lastColon);
354
+ }
355
+ const parts = base.split('::');
356
+ if (parts.length > 2) {
357
+ return null;
358
+ }
359
+ const head = parts[0] ? parts[0].split(':') : [];
360
+ const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
361
+ if (head.some(p => p === '') || tail.some(p => p === '')) {
362
+ return null;
363
+ }
364
+ const totalSegmentsNeeded = 8 - (ipv4Tail !== null ? 2 : 0);
365
+ let segments = [];
366
+ const parseHextets = (items) => {
367
+ const result = [];
368
+ for (const part of items) {
369
+ const num = parseInt(part || '0', 16);
370
+ if (isNaN(num) || num < 0 || num > 0xFFFF) {
371
+ return null;
372
+ }
373
+ result.push(num);
374
+ }
375
+ return result;
376
+ };
377
+ const headVals = parseHextets(head);
378
+ const tailVals = parseHextets(tail);
379
+ if (!headVals || !tailVals) {
380
+ return null;
381
+ }
382
+ const missing = totalSegmentsNeeded - (headVals.length + tailVals.length);
383
+ if (missing < 0) {
384
+ return null;
385
+ }
386
+ segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
387
+ if (segments.length !== totalSegmentsNeeded) {
388
+ return null;
389
+ }
390
+ if (ipv4Tail !== null) {
391
+ const high = (ipv4Tail >>> 16) & 0xFFFF;
392
+ const low = ipv4Tail & 0xFFFF;
393
+ segments.push(high, low);
394
+ }
395
+ if (segments.length !== 8) {
396
+ return null;
397
+ }
398
+ let value = 0n;
399
+ for (const part of segments) {
400
+ value = (value << 16n) + BigInt(part);
401
+ }
402
+ return value;
403
+ }
404
+ ipv6Mask(maskBits) {
405
+ if (maskBits === 0) {
406
+ return 0n;
407
+ }
408
+ const ones = (1n << BigInt(maskBits)) - 1n;
409
+ return BigInt.asUintN(128, ones << BigInt(128 - maskBits));
410
+ }
411
+ stripIpv6Brackets(value) {
412
+ return value.replace(/^\[/, '').replace(/\]$/, '');
413
+ }
414
+ /**
415
+ * Begin authorization flow
416
+ * Stores state and redirects to External Provider with MCP Callback URI
417
+ */
418
+ async authorize(client, params, res) {
419
+ await this.ensureEndpointsInitialized();
420
+ this.logger.info('Starting OAuth authorization', {
421
+ clientId: client.client_id,
422
+ scopes: params.scopes,
423
+ redirectUri: params.redirectUri,
424
+ registeredUris: client.redirect_uris,
425
+ });
426
+ // Validate redirect URI
427
+ if (client.redirect_uris && client.redirect_uris.length > 0 && !client.redirect_uris.includes(params.redirectUri)) {
428
+ this.logger.error('Invalid redirect URI', undefined, {
429
+ providedUri: params.redirectUri,
430
+ registeredUris: client.redirect_uris,
431
+ });
432
+ throw new Error('Unregistered redirect_uri');
433
+ }
434
+ // Validate redirect host against allowlist to prevent open redirect
435
+ if (!this.isAllowedRedirectHost(params.redirectUri)) {
436
+ this.logger.error('Redirect URI host not allowed', undefined, {
437
+ providedUri: params.redirectUri,
438
+ allowedHosts: this.config.allowed_redirect_hosts || ['localhost', '127.0.0.1'],
439
+ });
440
+ throw new Error('Redirect URI host not allowed');
441
+ }
442
+ const stateToken = randomUUID();
443
+ this.stateStore.set(stateToken, {
444
+ clientRedirectUri: params.redirectUri,
445
+ codeChallenge: params.codeChallenge,
446
+ originalState: params.state,
447
+ clientId: client.client_id,
448
+ scopes: params.scopes,
449
+ createdAt: Date.now(),
450
+ });
451
+ const authUrl = new URL(this.config.authorization_endpoint);
452
+ const clientId = this.config.client_id || client.client_id;
453
+ if (!this.config.redirect_uri) {
454
+ throw new Error('MCP4_OAUTH_REDIRECT_URI must be configured');
455
+ }
456
+ const callbackUri = this.config.redirect_uri;
457
+ authUrl.searchParams.set('client_id', clientId);
458
+ authUrl.searchParams.set('redirect_uri', callbackUri);
459
+ authUrl.searchParams.set('response_type', 'code');
460
+ authUrl.searchParams.set('state', stateToken);
461
+ if (params.scopes && params.scopes.length > 0) {
462
+ authUrl.searchParams.set('scope', params.scopes.join(' '));
463
+ }
464
+ else if (this.config.scopes && this.config.scopes.length > 0) {
465
+ authUrl.searchParams.set('scope', this.config.scopes.join(' '));
466
+ }
467
+ // NOTE: Do NOT forward PKCE parameters to external provider
468
+ // The MCP server acts as an OAuth proxy with client_secret (confidential client)
469
+ // PKCE is used only between Cursor <-> MCP, not between MCP <-> External Provider
470
+ // If we forwarded code_challenge, we would need code_verifier which only Cursor has
471
+ this.logger.info('Redirecting to external OAuth provider', {
472
+ authUrl: authUrl.toString(),
473
+ callbackUri,
474
+ stateToken,
475
+ hasClientSecret: !!this.config.client_secret,
476
+ });
477
+ res.redirect(authUrl.toString());
478
+ }
479
+ /**
480
+ * Handle callback from External Provider
481
+ * Exchanges code for tokens and redirects to Client with Internal Code
482
+ */
483
+ async handleCallback(req, res) {
484
+ await this.ensureEndpointsInitialized();
485
+ const { code, state, error } = req.query;
486
+ if (error) {
487
+ this.logger.error('OAuth callback error', undefined, { error, state });
488
+ // Sanitize error messages to prevent XSS
489
+ const safeError = escapeHtmlSafe(error);
490
+ res.status(400);
491
+ res.json({
492
+ error: safeError,
493
+ error_description: `Authorization failed: ${safeError}`
494
+ });
495
+ return;
496
+ }
497
+ if (!code || typeof code !== 'string') {
498
+ res.status(400).send('Missing authorization code');
499
+ return;
500
+ }
501
+ if (!state || typeof state !== 'string') {
502
+ res.status(400).send('Missing state parameter');
503
+ return;
504
+ }
505
+ const storedState = this.stateStore.get(state);
506
+ if (!storedState) {
507
+ res.status(400).send('Invalid or expired state');
508
+ return;
509
+ }
510
+ // Clean up state
511
+ this.stateStore.delete(state);
512
+ try {
513
+ // Exchange External Code for Tokens
514
+ const tokens = await this.exchangeCodeWithProvider(code, undefined, this.config.redirect_uri);
515
+ // Generate Internal Code
516
+ const internalCode = randomUUID();
517
+ // Store Internal Code -> Tokens mapping
518
+ const client = await this._clientsStore.getClient(storedState.clientId);
519
+ if (!client)
520
+ throw new Error('Client not found');
521
+ this.authorizationCodes.set(internalCode, {
522
+ client,
523
+ params: {
524
+ redirectUri: storedState.clientRedirectUri,
525
+ codeChallenge: storedState.codeChallenge,
526
+ scopes: storedState.scopes || [],
527
+ state: storedState.originalState
528
+ },
529
+ createdAt: Date.now(),
530
+ tokens
531
+ });
532
+ // Re-validate redirect URI host + registration before redirect (defense-in-depth)
533
+ if (!this.isAllowedRedirectHost(storedState.clientRedirectUri)) {
534
+ this.logger.error('Redirect URI host not allowed (callback)', undefined, {
535
+ storedUri: storedState.clientRedirectUri,
536
+ allowedHosts: this.config.allowed_redirect_hosts || ['localhost', '127.0.0.1'],
537
+ });
538
+ res.status(400).send('Redirect URI host not allowed');
539
+ return;
540
+ }
541
+ if (client.redirect_uris && client.redirect_uris.length > 0 && !client.redirect_uris.includes(storedState.clientRedirectUri)) {
542
+ this.logger.error('Stored redirect URI no longer registered', undefined, {
543
+ storedUri: storedState.clientRedirectUri,
544
+ registeredUris: client.redirect_uris,
545
+ });
546
+ res.status(400).send('Unregistered redirect_uri');
547
+ return;
548
+ }
549
+ // Redirect to Client
550
+ let clientUrl;
551
+ try {
552
+ // Allow custom schemes (e.g., vscode://, cursor://) as long as the host was validated above
553
+ clientUrl = new URL(storedState.clientRedirectUri);
554
+ }
555
+ catch {
556
+ res.status(400).send('Invalid redirect URI');
557
+ return;
558
+ }
559
+ clientUrl.searchParams.set('code', internalCode);
560
+ if (storedState.originalState) {
561
+ clientUrl.searchParams.set('state', storedState.originalState);
562
+ }
563
+ this.logger.info('Redirecting to client with internal code', {
564
+ clientUrl: clientUrl.toString(),
565
+ internalCode
566
+ });
567
+ // nosemgrep: javascript.express.open-redirect-deepsemgrep.open-redirect-deepsemgrep, javascript.express.web.tainted-redirect-express.tainted-redirect-express
568
+ res.redirect(clientUrl.toString());
569
+ }
570
+ catch (err) {
571
+ this.logger.error('Callback handling failed', err);
572
+ res.status(500).send('Internal Server Error during token exchange');
573
+ }
574
+ }
575
+ /**
576
+ * Get code challenge for authorization code (Internal)
577
+ */
578
+ async challengeForAuthorizationCode(client, authorizationCode) {
579
+ const codeData = this.authorizationCodes.get(authorizationCode);
580
+ if (!codeData) {
581
+ throw new Error('Invalid authorization code');
582
+ }
583
+ if (codeData.client.client_id !== client.client_id) {
584
+ throw new Error('Authorization code was not issued to this client');
585
+ }
586
+ return codeData.params.codeChallenge;
587
+ }
588
+ /**
589
+ * Exchange authorization code for access token (Internal)
590
+ */
591
+ async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
592
+ await this.ensureEndpointsInitialized();
593
+ this.logger.info('Exchanging internal authorization code', {
594
+ clientId: client.client_id,
595
+ });
596
+ const codeData = this.authorizationCodes.get(authorizationCode);
597
+ if (!codeData) {
598
+ throw new Error('Invalid authorization code');
599
+ }
600
+ if (codeData.client.client_id !== client.client_id) {
601
+ throw new Error('Authorization code was not issued to this client');
602
+ }
603
+ // Validate expiration (5 minutes)
604
+ const codeAge = Date.now() - codeData.createdAt;
605
+ const EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes
606
+ if (codeAge > EXPIRATION_MS) {
607
+ this.authorizationCodes.delete(authorizationCode);
608
+ throw new Error('Authorization code expired');
609
+ }
610
+ // Validate PKCE if code challenge was provided
611
+ if (codeData.params.codeChallenge) {
612
+ if (!codeVerifier) {
613
+ throw new Error('code_verifier is required for PKCE');
614
+ }
615
+ // Verify code challenge (S256 method)
616
+ const hash = createHash('sha256').update(codeVerifier).digest('base64url');
617
+ if (hash !== codeData.params.codeChallenge) {
618
+ throw new Error('Invalid code_verifier');
619
+ }
620
+ }
621
+ if (!codeData.tokens) {
622
+ throw new Error('No tokens associated with this code');
623
+ }
624
+ // Delete authorization code (single use)
625
+ this.authorizationCodes.delete(authorizationCode);
626
+ // Store access token for validation
627
+ const tokenData = {
628
+ token: codeData.tokens.access_token,
629
+ clientId: client.client_id,
630
+ scopes: codeData.params.scopes || this.config.scopes || [],
631
+ expiresAt: codeData.tokens.expires_in
632
+ ? Date.now() + codeData.tokens.expires_in * 1000
633
+ : undefined,
634
+ resource,
635
+ };
636
+ this.accessTokens.set(codeData.tokens.access_token, tokenData);
637
+ return codeData.tokens;
638
+ }
639
+ /**
640
+ * Exchange authorization code with external OAuth provider
641
+ */
642
+ async exchangeCodeWithProvider(code, codeVerifier, redirectUri) {
643
+ const tokenUrl = this.config.token_endpoint;
644
+ const body = new URLSearchParams({
645
+ grant_type: 'authorization_code',
646
+ code,
647
+ redirect_uri: redirectUri,
648
+ });
649
+ if (codeVerifier) {
650
+ body.set('code_verifier', codeVerifier);
651
+ }
652
+ if (this.config.client_id) {
653
+ body.set('client_id', this.config.client_id);
654
+ }
655
+ if (this.config.client_secret) {
656
+ body.set('client_secret', this.config.client_secret);
657
+ }
658
+ this.logger.debug('Exchanging code with external provider', { tokenUrl });
659
+ const response = await fetch(tokenUrl, {
660
+ method: 'POST',
661
+ headers: {
662
+ 'Content-Type': 'application/x-www-form-urlencoded',
663
+ 'Accept': 'application/json',
664
+ },
665
+ body: body.toString(),
666
+ });
667
+ if (!response.ok) {
668
+ const errorText = await response.text();
669
+ this.logger.error('Token exchange failed', undefined, {
670
+ httpStatus: response.status,
671
+ errorMessage: errorText,
672
+ });
673
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
674
+ }
675
+ const tokenResponse = await response.json();
676
+ return tokenResponse;
677
+ }
678
+ async exchangeRefreshToken(client, refreshToken, scopes, resource) {
679
+ await this.ensureEndpointsInitialized();
680
+ this.logger.info('Exchanging refresh token', { clientId: client.client_id });
681
+ const tokenUrl = this.config.token_endpoint;
682
+ const body = new URLSearchParams({
683
+ grant_type: 'refresh_token',
684
+ refresh_token: refreshToken,
685
+ });
686
+ if (scopes && scopes.length > 0) {
687
+ body.set('scope', scopes.join(' '));
688
+ }
689
+ if (this.config.client_id) {
690
+ body.set('client_id', this.config.client_id);
691
+ }
692
+ if (this.config.client_secret) {
693
+ body.set('client_secret', this.config.client_secret);
694
+ }
695
+ const response = await fetch(tokenUrl, {
696
+ method: 'POST',
697
+ headers: {
698
+ 'Content-Type': 'application/x-www-form-urlencoded',
699
+ 'Accept': 'application/json',
700
+ },
701
+ body: body.toString(),
702
+ });
703
+ if (!response.ok) {
704
+ const errorText = await response.text();
705
+ this.logger.error('Refresh token exchange failed', undefined, {
706
+ httpStatus: response.status,
707
+ errorMessage: errorText,
708
+ });
709
+ throw new Error(`Refresh token exchange failed: ${response.status}`);
710
+ }
711
+ const tokenResponse = await response.json();
712
+ const tokenData = {
713
+ token: tokenResponse.access_token,
714
+ clientId: client.client_id,
715
+ scopes: scopes || this.config.scopes || [],
716
+ expiresAt: tokenResponse.expires_in
717
+ ? Date.now() + tokenResponse.expires_in * 1000
718
+ : undefined,
719
+ resource,
720
+ };
721
+ this.accessTokens.set(tokenResponse.access_token, tokenData);
722
+ return tokenResponse;
723
+ }
724
+ async verifyAccessToken(token) {
725
+ const tokenData = this.accessTokens.get(token);
726
+ if (!tokenData) {
727
+ if (this.config.introspection_endpoint) {
728
+ return await this.introspectToken(token);
729
+ }
730
+ throw new Error('Invalid or expired token');
731
+ }
732
+ if (tokenData.expiresAt && tokenData.expiresAt < Date.now()) {
733
+ this.accessTokens.delete(token);
734
+ throw new Error('Token expired');
735
+ }
736
+ return {
737
+ token,
738
+ clientId: tokenData.clientId,
739
+ scopes: tokenData.scopes,
740
+ expiresAt: tokenData.expiresAt ? Math.floor(tokenData.expiresAt / 1000) : undefined,
741
+ resource: tokenData.resource,
742
+ };
743
+ }
744
+ async introspectToken(token) {
745
+ const introspectionUrl = this.config.introspection_endpoint;
746
+ if (!introspectionUrl) {
747
+ throw new Error('Introspection endpoint not configured');
748
+ }
749
+ const body = new URLSearchParams({ token });
750
+ if (this.config.client_id) {
751
+ body.set('client_id', this.config.client_id);
752
+ }
753
+ if (this.config.client_secret) {
754
+ body.set('client_secret', this.config.client_secret);
755
+ }
756
+ const response = await fetch(introspectionUrl, {
757
+ method: 'POST',
758
+ headers: {
759
+ 'Content-Type': 'application/x-www-form-urlencoded',
760
+ 'Accept': 'application/json',
761
+ },
762
+ body: body.toString(),
763
+ });
764
+ if (!response.ok) {
765
+ throw new Error(`Token introspection failed: ${response.status}`);
766
+ }
767
+ const introspectionResponse = await response.json();
768
+ if (!introspectionResponse.active) {
769
+ throw new Error('Token is not active');
770
+ }
771
+ return {
772
+ token,
773
+ clientId: introspectionResponse.client_id || 'unknown',
774
+ scopes: introspectionResponse.scope ? introspectionResponse.scope.split(' ') : [],
775
+ expiresAt: introspectionResponse.exp,
776
+ resource: introspectionResponse.aud ? new URL(introspectionResponse.aud) : undefined,
777
+ };
778
+ }
779
+ async revokeToken(client, request) {
780
+ this.logger.info('Revoking token', { clientId: client.client_id });
781
+ this.accessTokens.delete(request.token);
782
+ if (this.config.revocation_endpoint) {
783
+ await this.revokeTokenWithProvider(request.token);
784
+ }
785
+ }
786
+ async revokeTokenWithProvider(token) {
787
+ const revocationUrl = this.config.revocation_endpoint;
788
+ if (!revocationUrl)
789
+ return;
790
+ const body = new URLSearchParams({ token });
791
+ if (this.config.client_id) {
792
+ body.set('client_id', this.config.client_id);
793
+ }
794
+ if (this.config.client_secret) {
795
+ body.set('client_secret', this.config.client_secret);
796
+ }
797
+ const response = await fetch(revocationUrl, {
798
+ method: 'POST',
799
+ headers: {
800
+ 'Content-Type': 'application/x-www-form-urlencoded',
801
+ },
802
+ body: body.toString(),
803
+ });
804
+ if (!response.ok) {
805
+ this.logger.warn('Token revocation failed', { status: response.status });
806
+ }
807
+ }
808
+ /**
809
+ * Cleanup expired states, codes, and tokens
810
+ * Called periodically by HttpTransport
811
+ */
812
+ cleanup() {
813
+ const now = Date.now();
814
+ // 1. Cleanup expired states (10 minutes)
815
+ const STATE_TIMEOUT = OAUTH_RATE_LIMIT.WINDOW_MS;
816
+ for (const [state, data] of this.stateStore.entries()) {
817
+ if (now - data.createdAt > STATE_TIMEOUT) {
818
+ this.stateStore.delete(state);
819
+ }
820
+ }
821
+ // 2. Cleanup expired authorization codes (5 minutes)
822
+ const CODE_TIMEOUT = 5 * 60 * 1000;
823
+ for (const [code, data] of this.authorizationCodes.entries()) {
824
+ if (now - data.createdAt > CODE_TIMEOUT) {
825
+ this.authorizationCodes.delete(code);
826
+ }
827
+ }
828
+ // 3. Cleanup expired access tokens
829
+ for (const [token, data] of this.accessTokens.entries()) {
830
+ if (data.expiresAt && data.expiresAt < now) {
831
+ this.accessTokens.delete(token);
832
+ }
833
+ }
834
+ }
835
+ }
836
+ //# sourceMappingURL=oauth-provider.js.map