mcp4openapi 0.2.8 → 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 (358) hide show
  1. package/README.md +143 -63
  2. package/dist/scripts/validate-profile.js +3 -3
  3. package/dist/scripts/validate-profile.js.map +1 -1
  4. package/dist/src/argument-normalizer.d.ts +5 -0
  5. package/dist/src/argument-normalizer.d.ts.map +1 -0
  6. package/dist/src/argument-normalizer.js +61 -0
  7. package/dist/src/argument-normalizer.js.map +1 -0
  8. package/dist/src/auth/oauth-provider.d.ts +131 -0
  9. package/dist/src/auth/oauth-provider.d.ts.map +1 -0
  10. package/dist/src/auth/oauth-provider.js +839 -0
  11. package/dist/src/auth/oauth-provider.js.map +1 -0
  12. package/dist/src/cli-config.d.ts +9 -0
  13. package/dist/src/cli-config.d.ts.map +1 -0
  14. package/dist/src/cli-config.js +111 -0
  15. package/dist/src/cli-config.js.map +1 -0
  16. package/dist/src/core/cli-config.d.ts +9 -0
  17. package/dist/src/core/cli-config.d.ts.map +1 -0
  18. package/dist/src/core/cli-config.js +125 -0
  19. package/dist/src/core/cli-config.js.map +1 -0
  20. package/dist/src/core/constants.d.ts +86 -0
  21. package/dist/src/core/constants.d.ts.map +1 -0
  22. package/dist/src/core/constants.js +86 -0
  23. package/dist/src/core/constants.js.map +1 -0
  24. package/dist/src/core/errors.d.ts +59 -0
  25. package/dist/src/core/errors.d.ts.map +1 -0
  26. package/dist/src/core/errors.js +119 -0
  27. package/dist/src/core/errors.js.map +1 -0
  28. package/dist/src/core/filtering.d.ts +19 -0
  29. package/dist/src/core/filtering.d.ts.map +1 -0
  30. package/dist/src/core/filtering.js +292 -0
  31. package/dist/src/core/filtering.js.map +1 -0
  32. package/dist/src/core/index.d.ts +26 -0
  33. package/dist/src/core/index.d.ts.map +1 -0
  34. package/dist/src/core/index.js +276 -0
  35. package/dist/src/core/index.js.map +1 -0
  36. package/dist/src/core/lib.d.ts +8 -0
  37. package/dist/src/core/lib.d.ts.map +1 -0
  38. package/dist/src/core/lib.js +7 -0
  39. package/dist/src/core/lib.js.map +1 -0
  40. package/dist/src/core/logger.d.ts +59 -0
  41. package/dist/src/core/logger.d.ts.map +1 -0
  42. package/dist/src/core/logger.js +197 -0
  43. package/dist/src/core/logger.js.map +1 -0
  44. package/dist/src/core/metrics.d.ts +97 -0
  45. package/dist/src/core/metrics.d.ts.map +1 -0
  46. package/dist/src/core/metrics.js +273 -0
  47. package/dist/src/core/metrics.js.map +1 -0
  48. package/dist/src/core/naming-warnings.d.ts +23 -0
  49. package/dist/src/core/naming-warnings.d.ts.map +1 -0
  50. package/dist/src/core/naming-warnings.js +83 -0
  51. package/dist/src/core/naming-warnings.js.map +1 -0
  52. package/dist/src/core/naming.d.ts +58 -0
  53. package/dist/src/core/naming.d.ts.map +1 -0
  54. package/dist/src/core/naming.js +510 -0
  55. package/dist/src/core/naming.js.map +1 -0
  56. package/dist/src/errors.d.ts +6 -0
  57. package/dist/src/errors.d.ts.map +1 -1
  58. package/dist/src/errors.js +15 -6
  59. package/dist/src/errors.js.map +1 -1
  60. package/dist/src/filtering.d.ts +19 -0
  61. package/dist/src/filtering.d.ts.map +1 -0
  62. package/dist/src/filtering.js +292 -0
  63. package/dist/src/filtering.js.map +1 -0
  64. package/dist/src/generated-schemas.d.ts +290 -79
  65. package/dist/src/generated-schemas.d.ts.map +1 -1
  66. package/dist/src/generated-schemas.js +17 -2
  67. package/dist/src/generated-schemas.js.map +1 -1
  68. package/dist/src/http-transport-config.d.ts +6 -0
  69. package/dist/src/http-transport-config.d.ts.map +1 -0
  70. package/dist/src/http-transport-config.js +47 -0
  71. package/dist/src/http-transport-config.js.map +1 -0
  72. package/dist/src/http-transport.d.ts +63 -13
  73. package/dist/src/http-transport.d.ts.map +1 -1
  74. package/dist/src/http-transport.js +1045 -482
  75. package/dist/src/http-transport.js.map +1 -1
  76. package/dist/src/index.d.ts +1 -6
  77. package/dist/src/index.d.ts.map +1 -1
  78. package/dist/src/index.js +1 -170
  79. package/dist/src/index.js.map +1 -1
  80. package/dist/src/interceptors.d.ts +1 -0
  81. package/dist/src/interceptors.d.ts.map +1 -1
  82. package/dist/src/interceptors.js +73 -63
  83. package/dist/src/interceptors.js.map +1 -1
  84. package/dist/src/lib.d.ts +1 -7
  85. package/dist/src/lib.d.ts.map +1 -1
  86. package/dist/src/lib.js +1 -6
  87. package/dist/src/lib.js.map +1 -1
  88. package/dist/src/logger.d.ts +5 -0
  89. package/dist/src/logger.d.ts.map +1 -1
  90. package/dist/src/logger.js +9 -1
  91. package/dist/src/logger.js.map +1 -1
  92. package/dist/src/mcp/mcp-server-manager.d.ts +20 -0
  93. package/dist/src/mcp/mcp-server-manager.d.ts.map +1 -0
  94. package/dist/src/mcp/mcp-server-manager.js +38 -0
  95. package/dist/src/mcp/mcp-server-manager.js.map +1 -0
  96. package/dist/src/mcp/mcp-server.d.ts +205 -0
  97. package/dist/src/mcp/mcp-server.d.ts.map +1 -0
  98. package/dist/src/mcp/mcp-server.js +1473 -0
  99. package/dist/src/mcp/mcp-server.js.map +1 -0
  100. package/dist/src/mcp-server-manager.d.ts +20 -0
  101. package/dist/src/mcp-server-manager.d.ts.map +1 -0
  102. package/dist/src/mcp-server-manager.js +38 -0
  103. package/dist/src/mcp-server-manager.js.map +1 -0
  104. package/dist/src/mcp-server.d.ts +28 -0
  105. package/dist/src/mcp-server.d.ts.map +1 -1
  106. package/dist/src/mcp-server.js +406 -109
  107. package/dist/src/mcp-server.js.map +1 -1
  108. package/dist/src/metrics.d.ts +11 -0
  109. package/dist/src/metrics.d.ts.map +1 -1
  110. package/dist/src/metrics.js +61 -0
  111. package/dist/src/metrics.js.map +1 -1
  112. package/dist/src/oauth-provider.d.ts +5 -0
  113. package/dist/src/oauth-provider.d.ts.map +1 -1
  114. package/dist/src/oauth-provider.js +29 -1
  115. package/dist/src/oauth-provider.js.map +1 -1
  116. package/dist/src/openapi/openapi-parser.d.ts +70 -0
  117. package/dist/src/openapi/openapi-parser.d.ts.map +1 -0
  118. package/dist/src/openapi/openapi-parser.js +458 -0
  119. package/dist/src/openapi/openapi-parser.js.map +1 -0
  120. package/dist/src/profile/profile-loader.d.ts +78 -0
  121. package/dist/src/profile/profile-loader.d.ts.map +1 -0
  122. package/dist/src/profile/profile-loader.js +490 -0
  123. package/dist/src/profile/profile-loader.js.map +1 -0
  124. package/dist/src/profile/profile-registry.d.ts +19 -0
  125. package/dist/src/profile/profile-registry.d.ts.map +1 -0
  126. package/dist/src/profile/profile-registry.js +43 -0
  127. package/dist/src/profile/profile-registry.js.map +1 -0
  128. package/dist/src/profile/profile-resolver.d.ts +41 -0
  129. package/dist/src/profile/profile-resolver.d.ts.map +1 -0
  130. package/dist/src/profile/profile-resolver.js +324 -0
  131. package/dist/src/profile/profile-resolver.js.map +1 -0
  132. package/dist/src/profile/startup-profile.d.ts +17 -0
  133. package/dist/src/profile/startup-profile.d.ts.map +1 -0
  134. package/dist/src/profile/startup-profile.js +30 -0
  135. package/dist/src/profile/startup-profile.js.map +1 -0
  136. package/dist/src/profile/startup-validation.d.ts +11 -0
  137. package/dist/src/profile/startup-validation.d.ts.map +1 -0
  138. package/dist/src/profile/startup-validation.js +21 -0
  139. package/dist/src/profile/startup-validation.js.map +1 -0
  140. package/dist/src/profile-loader.d.ts +1 -0
  141. package/dist/src/profile-loader.d.ts.map +1 -1
  142. package/dist/src/profile-loader.js +14 -3
  143. package/dist/src/profile-loader.js.map +1 -1
  144. package/dist/src/profile-registry.d.ts +18 -0
  145. package/dist/src/profile-registry.d.ts.map +1 -0
  146. package/dist/src/profile-registry.js +26 -0
  147. package/dist/src/profile-registry.js.map +1 -0
  148. package/dist/src/profile-resolver.d.ts +19 -0
  149. package/dist/src/profile-resolver.d.ts.map +1 -0
  150. package/dist/src/profile-resolver.js +167 -0
  151. package/dist/src/profile-resolver.js.map +1 -0
  152. package/dist/src/proxy-executor.d.ts.map +1 -1
  153. package/dist/src/proxy-executor.js +7 -0
  154. package/dist/src/proxy-executor.js.map +1 -1
  155. package/dist/src/startup-profile.d.ts +17 -0
  156. package/dist/src/startup-profile.d.ts.map +1 -0
  157. package/dist/src/startup-profile.js +30 -0
  158. package/dist/src/startup-profile.js.map +1 -0
  159. package/dist/src/startup-validation.d.ts +11 -0
  160. package/dist/src/startup-validation.d.ts.map +1 -0
  161. package/dist/src/startup-validation.js +21 -0
  162. package/dist/src/startup-validation.js.map +1 -0
  163. package/dist/src/testing/dynamic-mock-server.d.ts +24 -0
  164. package/dist/src/testing/dynamic-mock-server.d.ts.map +1 -0
  165. package/dist/src/testing/dynamic-mock-server.js +138 -0
  166. package/dist/src/testing/dynamic-mock-server.js.map +1 -0
  167. package/dist/src/testing/request-assertions.d.ts +5 -0
  168. package/dist/src/testing/request-assertions.d.ts.map +1 -0
  169. package/dist/src/testing/request-assertions.js +165 -0
  170. package/dist/src/testing/request-assertions.js.map +1 -0
  171. package/dist/src/testing/template-utils.d.ts +10 -0
  172. package/dist/src/testing/template-utils.d.ts.map +1 -0
  173. package/dist/src/testing/template-utils.js +72 -0
  174. package/dist/src/testing/template-utils.js.map +1 -0
  175. package/dist/src/testing/test-http-utils.d.ts +1 -1
  176. package/dist/src/testing/test-http-utils.d.ts.map +1 -1
  177. package/dist/src/testing/test-http-utils.js +1 -1
  178. package/dist/src/testing/test-http-utils.js.map +1 -1
  179. package/dist/src/testing/test-loader.d.ts +6 -0
  180. package/dist/src/testing/test-loader.d.ts.map +1 -0
  181. package/dist/src/testing/test-loader.js +212 -0
  182. package/dist/src/testing/test-loader.js.map +1 -0
  183. package/dist/src/testing/test-schema.d.ts +1270 -0
  184. package/dist/src/testing/test-schema.d.ts.map +1 -0
  185. package/dist/src/testing/test-schema.js +76 -0
  186. package/dist/src/testing/test-schema.js.map +1 -0
  187. package/dist/src/tool-filter/compat.d.ts +49 -0
  188. package/dist/src/tool-filter/compat.d.ts.map +1 -0
  189. package/dist/src/tool-filter/compat.js +72 -0
  190. package/dist/src/tool-filter/compat.js.map +1 -0
  191. package/dist/src/tool-filter/config/env-config-parser.d.ts +38 -0
  192. package/dist/src/tool-filter/config/env-config-parser.d.ts.map +1 -0
  193. package/dist/src/tool-filter/config/env-config-parser.js +103 -0
  194. package/dist/src/tool-filter/config/env-config-parser.js.map +1 -0
  195. package/dist/src/tool-filter/config/header-config-parser.d.ts +37 -0
  196. package/dist/src/tool-filter/config/header-config-parser.d.ts.map +1 -0
  197. package/dist/src/tool-filter/config/header-config-parser.js +118 -0
  198. package/dist/src/tool-filter/config/header-config-parser.js.map +1 -0
  199. package/dist/src/tool-filter/errors.d.ts +18 -0
  200. package/dist/src/tool-filter/errors.d.ts.map +1 -0
  201. package/dist/src/tool-filter/errors.js +21 -0
  202. package/dist/src/tool-filter/errors.js.map +1 -0
  203. package/dist/src/tool-filter/filter/filter-engine.d.ts +45 -0
  204. package/dist/src/tool-filter/filter/filter-engine.d.ts.map +1 -0
  205. package/dist/src/tool-filter/filter/filter-engine.js +94 -0
  206. package/dist/src/tool-filter/filter/filter-engine.js.map +1 -0
  207. package/dist/src/tool-filter/filter/filter-rules.d.ts +44 -0
  208. package/dist/src/tool-filter/filter/filter-rules.d.ts.map +1 -0
  209. package/dist/src/tool-filter/filter/filter-rules.js +72 -0
  210. package/dist/src/tool-filter/filter/filter-rules.js.map +1 -0
  211. package/dist/src/tool-filter/filter/global-tool-filter.d.ts +40 -0
  212. package/dist/src/tool-filter/filter/global-tool-filter.d.ts.map +1 -0
  213. package/dist/src/tool-filter/filter/global-tool-filter.js +92 -0
  214. package/dist/src/tool-filter/filter/global-tool-filter.js.map +1 -0
  215. package/dist/src/tool-filter/filter/session-tool-filter.d.ts +29 -0
  216. package/dist/src/tool-filter/filter/session-tool-filter.d.ts.map +1 -0
  217. package/dist/src/tool-filter/filter/session-tool-filter.js +69 -0
  218. package/dist/src/tool-filter/filter/session-tool-filter.js.map +1 -0
  219. package/dist/src/tool-filter/index.d.ts +25 -0
  220. package/dist/src/tool-filter/index.d.ts.map +1 -0
  221. package/dist/src/tool-filter/index.js +30 -0
  222. package/dist/src/tool-filter/index.js.map +1 -0
  223. package/dist/src/tool-filter/integration/tool-filter-service.d.ts +44 -0
  224. package/dist/src/tool-filter/integration/tool-filter-service.d.ts.map +1 -0
  225. package/dist/src/tool-filter/integration/tool-filter-service.js +68 -0
  226. package/dist/src/tool-filter/integration/tool-filter-service.js.map +1 -0
  227. package/dist/src/tool-filter/operation/operation-classifier.d.ts +20 -0
  228. package/dist/src/tool-filter/operation/operation-classifier.d.ts.map +1 -0
  229. package/dist/src/tool-filter/operation/operation-classifier.js +26 -0
  230. package/dist/src/tool-filter/operation/operation-classifier.js.map +1 -0
  231. package/dist/src/tool-filter/operation/operation-detector.d.ts +30 -0
  232. package/dist/src/tool-filter/operation/operation-detector.d.ts.map +1 -0
  233. package/dist/src/tool-filter/operation/operation-detector.js +96 -0
  234. package/dist/src/tool-filter/operation/operation-detector.js.map +1 -0
  235. package/dist/src/tool-filter/operation/operation-resolver.d.ts +22 -0
  236. package/dist/src/tool-filter/operation/operation-resolver.d.ts.map +1 -0
  237. package/dist/src/tool-filter/operation/operation-resolver.js +32 -0
  238. package/dist/src/tool-filter/operation/operation-resolver.js.map +1 -0
  239. package/dist/src/tool-filter/regex/regex-compiler.d.ts +22 -0
  240. package/dist/src/tool-filter/regex/regex-compiler.d.ts.map +1 -0
  241. package/dist/src/tool-filter/regex/regex-compiler.js +56 -0
  242. package/dist/src/tool-filter/regex/regex-compiler.js.map +1 -0
  243. package/dist/src/tool-filter/regex/regex-validator.d.ts +24 -0
  244. package/dist/src/tool-filter/regex/regex-validator.d.ts.map +1 -0
  245. package/dist/src/tool-filter/regex/regex-validator.js +58 -0
  246. package/dist/src/tool-filter/regex/regex-validator.js.map +1 -0
  247. package/dist/src/tool-filter/types.d.ts +92 -0
  248. package/dist/src/tool-filter/types.d.ts.map +1 -0
  249. package/dist/src/tool-filter/types.js +5 -0
  250. package/dist/src/tool-filter/types.js.map +1 -0
  251. package/dist/src/tool-filter/utils.d.ts +11 -0
  252. package/dist/src/tool-filter/utils.d.ts.map +1 -0
  253. package/dist/src/tool-filter/utils.js +13 -0
  254. package/dist/src/tool-filter/utils.js.map +1 -0
  255. package/dist/src/tool-filter.d.ts +65 -0
  256. package/dist/src/tool-filter.d.ts.map +1 -0
  257. package/dist/src/tool-filter.js +471 -0
  258. package/dist/src/tool-filter.js.map +1 -0
  259. package/dist/src/tool-generator.d.ts +1 -0
  260. package/dist/src/tool-generator.d.ts.map +1 -1
  261. package/dist/src/tool-generator.js +15 -6
  262. package/dist/src/tool-generator.js.map +1 -1
  263. package/dist/src/tooling/composite-executor.d.ts +77 -0
  264. package/dist/src/tooling/composite-executor.d.ts.map +1 -0
  265. package/dist/src/tooling/composite-executor.js +198 -0
  266. package/dist/src/tooling/composite-executor.js.map +1 -0
  267. package/dist/src/tooling/dag-executor.d.ts +49 -0
  268. package/dist/src/tooling/dag-executor.d.ts.map +1 -0
  269. package/dist/src/tooling/dag-executor.js +138 -0
  270. package/dist/src/tooling/dag-executor.js.map +1 -0
  271. package/dist/src/tooling/proxy-executor.d.ts +86 -0
  272. package/dist/src/tooling/proxy-executor.d.ts.map +1 -0
  273. package/dist/src/tooling/proxy-executor.js +501 -0
  274. package/dist/src/tooling/proxy-executor.js.map +1 -0
  275. package/dist/src/tooling/tool-generator.d.ts +67 -0
  276. package/dist/src/tooling/tool-generator.d.ts.map +1 -0
  277. package/dist/src/tooling/tool-generator.js +222 -0
  278. package/dist/src/tooling/tool-generator.js.map +1 -0
  279. package/dist/src/transport/http-client-factory.d.ts +65 -0
  280. package/dist/src/transport/http-client-factory.d.ts.map +1 -0
  281. package/dist/src/transport/http-client-factory.js +143 -0
  282. package/dist/src/transport/http-client-factory.js.map +1 -0
  283. package/dist/src/transport/http-transport-config.d.ts +6 -0
  284. package/dist/src/transport/http-transport-config.d.ts.map +1 -0
  285. package/dist/src/transport/http-transport-config.js +63 -0
  286. package/dist/src/transport/http-transport-config.js.map +1 -0
  287. package/dist/src/transport/http-transport.d.ts +329 -0
  288. package/dist/src/transport/http-transport.d.ts.map +1 -0
  289. package/dist/src/transport/http-transport.js +2584 -0
  290. package/dist/src/transport/http-transport.js.map +1 -0
  291. package/dist/src/transport/interceptors.d.ts +119 -0
  292. package/dist/src/transport/interceptors.d.ts.map +1 -0
  293. package/dist/src/transport/interceptors.js +413 -0
  294. package/dist/src/transport/interceptors.js.map +1 -0
  295. package/dist/src/transport/profile-index.d.ts +84 -0
  296. package/dist/src/transport/profile-index.d.ts.map +1 -0
  297. package/dist/src/transport/profile-index.js +405 -0
  298. package/dist/src/transport/profile-index.js.map +1 -0
  299. package/dist/src/types/http-transport.d.ts +26 -0
  300. package/dist/src/types/http-transport.d.ts.map +1 -1
  301. package/dist/src/types/openapi.d.ts +3 -0
  302. package/dist/src/types/openapi.d.ts.map +1 -1
  303. package/dist/src/types/profile.d.ts +16 -1
  304. package/dist/src/types/profile.d.ts.map +1 -1
  305. package/dist/src/validation/argument-normalizer.d.ts +6 -0
  306. package/dist/src/validation/argument-normalizer.d.ts.map +1 -0
  307. package/dist/src/validation/argument-normalizer.js +70 -0
  308. package/dist/src/validation/argument-normalizer.js.map +1 -0
  309. package/dist/src/validation/jsonrpc-validator.d.ts +27 -0
  310. package/dist/src/validation/jsonrpc-validator.d.ts.map +1 -0
  311. package/dist/src/validation/jsonrpc-validator.js +58 -0
  312. package/dist/src/validation/jsonrpc-validator.js.map +1 -0
  313. package/dist/src/validation/schema-validator.d.ts +30 -0
  314. package/dist/src/validation/schema-validator.d.ts.map +1 -0
  315. package/dist/src/validation/schema-validator.js +128 -0
  316. package/dist/src/validation/schema-validator.js.map +1 -0
  317. package/dist/src/validation/validation-utils.d.ts +49 -0
  318. package/dist/src/validation/validation-utils.d.ts.map +1 -0
  319. package/dist/src/validation/validation-utils.js +139 -0
  320. package/dist/src/validation/validation-utils.js.map +1 -0
  321. package/html/profile-index.html +386 -0
  322. package/package.json +10 -3
  323. package/profile-schema.json +77 -3
  324. package/profiles/gitlab/developer-profile-oauth.json +1520 -0
  325. package/profiles/gitlab/developer-profile-oauth.test.json +3432 -0
  326. package/profiles/gitlab/developer-profile.json +1508 -0
  327. package/profiles/gitlab/developer-profile.test.json +3432 -0
  328. package/profiles/gitlab/openapi.yaml +6891 -0
  329. package/profiles/n8n/openapi.yaml +2441 -0
  330. package/profiles/n8n/profile-optimized.json +965 -0
  331. package/profiles/n8n/profile-optimized.test.json +1078 -0
  332. package/profiles/n8n/profile.json +1033 -0
  333. package/profiles/n8n/profile.test.json +983 -0
  334. package/profiles/n8n-nodes/openapi.yaml +24 -0
  335. package/profiles/n8n-nodes/profile-nodes.json +44 -0
  336. package/profiles/n8n-nodes/profile-nodes.test.json +91 -0
  337. package/profiles/semgrep/openapi.yaml +4706 -0
  338. package/profiles/semgrep/profile.json +692 -0
  339. package/profiles/semgrep/profile.test.json +471 -0
  340. package/profiles/youtrack/openapi.json +16976 -0
  341. package/profiles/youtrack/profile.json +608 -0
  342. package/profiles/youtrack/profile.test.json +1926 -0
  343. package/dist/src/testing/fixtures.d.ts +0 -684
  344. package/dist/src/testing/fixtures.d.ts.map +0 -1
  345. package/dist/src/testing/fixtures.js +0 -528
  346. package/dist/src/testing/fixtures.js.map +0 -1
  347. package/dist/src/testing/mock-gitlab-server.d.ts +0 -43
  348. package/dist/src/testing/mock-gitlab-server.d.ts.map +0 -1
  349. package/dist/src/testing/mock-gitlab-server.js +0 -1026
  350. package/dist/src/testing/mock-gitlab-server.js.map +0 -1
  351. package/dist/src/testing/mock-semgrep-server.d.ts +0 -32
  352. package/dist/src/testing/mock-semgrep-server.d.ts.map +0 -1
  353. package/dist/src/testing/mock-semgrep-server.js +0 -213
  354. package/dist/src/testing/mock-semgrep-server.js.map +0 -1
  355. package/dist/src/testing/mock-youtrack-server.d.ts +0 -11
  356. package/dist/src/testing/mock-youtrack-server.d.ts.map +0 -1
  357. package/dist/src/testing/mock-youtrack-server.js +0 -152
  358. package/dist/src/testing/mock-youtrack-server.js.map +0 -1
@@ -0,0 +1,2584 @@
1
+ /**
2
+ * HTTP Streamable Transport for MCP
3
+ *
4
+ * Implements MCP Specification 2025-03-26
5
+ * https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
6
+ *
7
+ * Why: Enables remote MCP server access with SSE streaming, session management,
8
+ * and resumability for reliable communication over HTTP.
9
+ */
10
+ import express from 'express';
11
+ import https from 'https';
12
+ import fs from 'fs';
13
+ import crypto from 'crypto';
14
+ import { isIP } from 'node:net';
15
+ import rateLimit from 'express-rate-limit';
16
+ import { isInitializeRequest } from '../validation/jsonrpc-validator.js';
17
+ import { MetricsCollector } from '../core/metrics.js';
18
+ import { ExternalOAuthProvider } from '../auth/oauth-provider.js';
19
+ import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from '../core/constants.js';
20
+ import { escapeHtmlSafe, isSafePropertyName } from '../validation/validation-utils.js';
21
+ import { AuthenticationError, AuthorizationError, ConfigurationError, RateLimitError, ValidationError, generateCorrelationId, } from '../core/errors.js';
22
+ import { parseFilteringHeader, normalizeFilteringHeaderValue } from '../core/filtering.js';
23
+ import { ToolFilterService, EnvConfigParser, HeaderConfigParser, RegexCompiler, RegexValidator, OperationClassifier, OpenAPIOperationResolver, OperationDetector, normalizeToolFilterHeaderValue, parseSessionToolFilterHeader, } from '../tool-filter/index.js';
24
+ import { buildProfileIndexPayload, loadProfileIndexTemplate, parseAcceptLanguage, renderProfileIndexHtml, } from './profile-index.js';
25
+ const DEFAULT_MAX_TOKEN_LENGTH = 1000;
26
+ export class HttpTransport {
27
+ static { this.PROFILE_HINT_TTL_MS = 10 * 60 * 1000; }
28
+ constructor(config, logger) {
29
+ this.server = null;
30
+ this.metrics = null;
31
+ this.cleanupInterval = null;
32
+ this.messageHandler = null;
33
+ this.profileContextProvider = null;
34
+ this.profileStates = new Map();
35
+ this.oauthRedirectHostCache = new Map();
36
+ this.warnedMissingOAuthRedirectEnvVars = new Set();
37
+ this.profileHintsByClient = new Map();
38
+ this.profileIndexProvider = null;
39
+ this.hasWarnedAboutBinding = false;
40
+ /**
41
+ * Session destruction listeners for cleanup in other components
42
+ */
43
+ this.sessionDestroyedListeners = [];
44
+ // Freeze config to prevent runtime mutation of security-critical settings (allowedOrigins, rate limits, etc.)
45
+ this.config = Object.freeze({ ...config });
46
+ this.logger = logger;
47
+ // Initialize metrics if enabled
48
+ if (config.metricsEnabled) {
49
+ this.metrics = new MetricsCollector({
50
+ enabled: true,
51
+ prefix: 'mcp_',
52
+ });
53
+ }
54
+ this.app = express();
55
+ if (this.config.trustProxy !== undefined) {
56
+ this.app.set('trust proxy', this.config.trustProxy);
57
+ }
58
+ this.setupMiddleware();
59
+ this.setupRoutes();
60
+ }
61
+ getMetricsCollector() {
62
+ return this.metrics;
63
+ }
64
+ setProfileIndexProvider(provider) {
65
+ this.profileIndexProvider = provider;
66
+ }
67
+ /**
68
+ * Setup Express middleware
69
+ *
70
+ * Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
71
+ */
72
+ setupMiddleware() {
73
+ // Security: standard headers
74
+ this.app.disable('x-powered-by');
75
+ this.app.use((req, res, next) => {
76
+ res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'");
77
+ res.setHeader('X-Frame-Options', 'DENY');
78
+ res.setHeader('X-Content-Type-Options', 'nosniff');
79
+ res.setHeader('Referrer-Policy', 'no-referrer');
80
+ res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
81
+ // Additional security headers
82
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
83
+ res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
84
+ res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
85
+ res.setHeader('X-DNS-Prefetch-Control', 'off');
86
+ next();
87
+ });
88
+ // Request logging (before any middleware)
89
+ this.app.use((req, res, next) => {
90
+ this.logger.debug('Request received', {
91
+ method: req.method,
92
+ url: req.url,
93
+ path: req.path,
94
+ userAgent: req.get('user-agent'),
95
+ ip: req.ip,
96
+ });
97
+ next();
98
+ });
99
+ // DNS rebinding protection when binding to localhost
100
+ // Deny requests with mismatched Host headers to prevent DNS rebinding attacks
101
+ // Applies when server host is localhost/127.0.0.1, regardless of auth configuration
102
+ this.app.use((req, res, next) => {
103
+ const hostCfg = this.config.host?.toLowerCase();
104
+ if (hostCfg === 'localhost' || hostCfg === '127.0.0.1') {
105
+ const hostHeader = (req.headers['host'] || '').toString().toLowerCase();
106
+ const expectedHosts = new Set(['localhost', '127.0.0.1']);
107
+ const headerHostOnly = hostHeader.split(':')[0];
108
+ if (!expectedHosts.has(headerHostOnly)) {
109
+ this.logger.warn('DNS rebinding protection: invalid Host header', {
110
+ hostHeader,
111
+ expected: Array.from(expectedHosts),
112
+ });
113
+ res.status(403).json({ error: 'Forbidden' });
114
+ return;
115
+ }
116
+ }
117
+ next();
118
+ });
119
+ // JSON body parser
120
+ // Limit set to 10MB to support large tool inputs/results (e.g. file content)
121
+ // while preventing massive DoS attacks. Default is 100kb.
122
+ this.app.use(express.json({ limit: '10mb' }));
123
+ // Metrics: Track request start time
124
+ this.app.use((req, res, next) => {
125
+ req.startTime = Date.now();
126
+ // Log response
127
+ const originalSend = res.send;
128
+ const originalJson = res.json;
129
+ const logger = this.logger;
130
+ res.send = function (body) {
131
+ logger.debug('Outgoing response', {
132
+ method: req.method,
133
+ url: req.url,
134
+ status: res.statusCode,
135
+ contentType: res.get('content-type'),
136
+ bodyLength: body ? body.length : 0,
137
+ bodyPreview: typeof body === 'string' ? body.substring(0, 200) : '[object]'
138
+ });
139
+ return originalSend.call(this, body);
140
+ };
141
+ res.json = function (body) {
142
+ logger.debug('Outgoing JSON response', {
143
+ method: req.method,
144
+ url: req.url,
145
+ status: res.statusCode,
146
+ body
147
+ });
148
+ return originalJson.call(this, body);
149
+ };
150
+ next();
151
+ });
152
+ // Debug: Log all requests
153
+ this.app.use((req, res, next) => {
154
+ this.logger.debug('Incoming request', {
155
+ method: req.method,
156
+ url: req.url,
157
+ path: req.path,
158
+ headers: {
159
+ 'user-agent': req.headers['user-agent'],
160
+ 'accept': req.headers.accept,
161
+ 'content-type': req.headers['content-type'],
162
+ 'authorization': req.headers.authorization ? '[REDACTED]' : undefined
163
+ },
164
+ ip: req.ip
165
+ });
166
+ next();
167
+ });
168
+ // Capture profile hints for clients using profile routing
169
+ this.app.use((req, _res, next) => {
170
+ if (this.config.profileRoutingEnabled) {
171
+ const profileId = this.resolveProfileIdFromPath(req.path);
172
+ if (profileId) {
173
+ this.storeProfileHint(req, profileId);
174
+ }
175
+ }
176
+ next();
177
+ });
178
+ // Security: Origin validation (DNS rebinding protection)
179
+ this.app.use((req, res, next) => {
180
+ const origin = req.headers.origin;
181
+ // Warn if binding to 0.0.0.0
182
+ if (this.config.host === '0.0.0.0' && !this.hasWarnedAboutBinding) {
183
+ this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
184
+ this.hasWarnedAboutBinding = true;
185
+ }
186
+ // Validate Origin header
187
+ // We do not skip this check for localhost, as requests to localhost can still be CSRF targets
188
+ if (!origin) {
189
+ next();
190
+ return;
191
+ }
192
+ this.isAllowedOriginForRequest(origin, req)
193
+ .then((allowed) => {
194
+ if (!allowed) {
195
+ this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
196
+ res.status(HTTP_STATUS.FORBIDDEN).json({
197
+ error: 'Forbidden',
198
+ message: 'Origin not allowed'
199
+ });
200
+ return;
201
+ }
202
+ next();
203
+ })
204
+ .catch((error) => {
205
+ this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
206
+ res.status(HTTP_STATUS.FORBIDDEN).json({
207
+ error: 'Forbidden',
208
+ message: 'Origin not allowed'
209
+ });
210
+ });
211
+ });
212
+ // Extract session ID from header
213
+ this.app.use((req, res, next) => {
214
+ const sessionId = req.headers['mcp-session-id'];
215
+ if (sessionId) {
216
+ req.sessionId = sessionId;
217
+ }
218
+ next();
219
+ });
220
+ }
221
+ setProfileContextProvider(provider) {
222
+ this.profileContextProvider = provider;
223
+ }
224
+ getDefaultProfileId() {
225
+ if (this.config.defaultProfileId) {
226
+ return this.config.defaultProfileId;
227
+ }
228
+ if (this.config.profileRoutingEnabled) {
229
+ return undefined;
230
+ }
231
+ return 'default';
232
+ }
233
+ buildDefaultProfileContext() {
234
+ const profileId = this.getDefaultProfileId();
235
+ if (!profileId) {
236
+ return null;
237
+ }
238
+ return {
239
+ profileId,
240
+ oauthConfig: this.config.oauthConfig,
241
+ authConfigs: this.config.authConfigs,
242
+ baseUrl: this.config.baseUrl,
243
+ rateLimitOAuthMax: this.config.rateLimitOAuthMax,
244
+ rateLimitOAuthWindowMs: this.config.rateLimitOAuthWindowMs,
245
+ resourceName: this.config.resourceName,
246
+ resourceDocumentation: this.config.resourceDocumentation,
247
+ parser: this.config.parser,
248
+ };
249
+ }
250
+ async getProfileState(profileId) {
251
+ const existing = this.profileStates.get(profileId);
252
+ if (existing) {
253
+ return existing;
254
+ }
255
+ let context = null;
256
+ if (this.profileContextProvider) {
257
+ try {
258
+ context = await this.profileContextProvider(profileId);
259
+ }
260
+ catch (error) {
261
+ if (error instanceof ConfigurationError && error.message === 'Profile not found') {
262
+ this.logger.warn('Profile not found during request', { profileId });
263
+ return null;
264
+ }
265
+ throw error;
266
+ }
267
+ }
268
+ else {
269
+ const defaultContext = this.buildDefaultProfileContext();
270
+ if (defaultContext?.profileId === profileId) {
271
+ context = defaultContext;
272
+ }
273
+ }
274
+ if (!context) {
275
+ return null;
276
+ }
277
+ let oauthProvider = null;
278
+ if (context.oauthConfig) {
279
+ this.logger.info('Initializing OAuth provider with config', {
280
+ profileId,
281
+ hasClientId: !!context.oauthConfig.client_id,
282
+ });
283
+ oauthProvider = new ExternalOAuthProvider(context.oauthConfig, this.logger);
284
+ this.logger.info('OAuth provider initialized', {
285
+ profileId,
286
+ endpoint: oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
287
+ hasIssuer: !!context.oauthConfig.issuer,
288
+ });
289
+ }
290
+ else {
291
+ this.logger.info('No OAuth config provided - OAuth provider not initialized', { profileId });
292
+ }
293
+ const state = {
294
+ profileId,
295
+ context,
296
+ oauthProvider,
297
+ oauthTokensByAccessToken: new Map(),
298
+ sessions: new Map(),
299
+ };
300
+ this.profileStates.set(profileId, state);
301
+ return state;
302
+ }
303
+ getProfileIdForRequest(req) {
304
+ if (req.profileId) {
305
+ return req.profileId;
306
+ }
307
+ return this.getDefaultProfileId();
308
+ }
309
+ async getProfileStateForRequest(req) {
310
+ const profileId = this.getProfileIdForRequest(req);
311
+ if (!profileId) {
312
+ return null;
313
+ }
314
+ return await this.getProfileState(profileId);
315
+ }
316
+ /**
317
+ * Check if origin is allowed
318
+ *
319
+ * Why: Prevent DNS rebinding attacks
320
+ *
321
+ * Supports:
322
+ * - Exact hostname: 'example.com', 'api.example.com'
323
+ * - Wildcard subdomain: '*.example.com'
324
+ * - IPv4 CIDR: '192.168.1.0/24', '10.0.0.0/8'
325
+ * - IPv4 exact: '192.168.1.100'
326
+ */
327
+ isAllowedOrigin(origin) {
328
+ try {
329
+ const url = new URL(origin);
330
+ const hostname = url.hostname;
331
+ // Always allow localhost
332
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
333
+ return true;
334
+ }
335
+ // Allow configured host
336
+ if (hostname === this.config.host) {
337
+ return true;
338
+ }
339
+ // Allow OAuth redirect URI hosts (initialized + cached + configured)
340
+ for (const redirectHost of this.getOAuthRedirectHostPatterns()) {
341
+ if (this.matchOrigin(hostname, redirectHost)) {
342
+ return true;
343
+ }
344
+ }
345
+ // Check custom allowed origins
346
+ if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
347
+ for (const allowed of this.config.allowedOrigins) {
348
+ if (this.matchOrigin(hostname, allowed)) {
349
+ return true;
350
+ }
351
+ }
352
+ }
353
+ return false;
354
+ }
355
+ catch {
356
+ return false;
357
+ }
358
+ }
359
+ getOAuthRedirectHostPatterns() {
360
+ const hosts = new Set();
361
+ const defaultProfileId = this.getDefaultProfileId() ?? 'default';
362
+ for (const state of this.profileStates.values()) {
363
+ const redirectUri = state.oauthProvider?.redirectUri;
364
+ if (!redirectUri) {
365
+ continue;
366
+ }
367
+ try {
368
+ const redirectUrl = new URL(redirectUri);
369
+ hosts.add(redirectUrl.hostname);
370
+ }
371
+ catch {
372
+ // Ignore invalid URL
373
+ }
374
+ }
375
+ if (this.config.oauthConfig) {
376
+ for (const pattern of this.extractRedirectHostPatterns(this.config.oauthConfig, defaultProfileId)) {
377
+ hosts.add(pattern);
378
+ }
379
+ }
380
+ for (const patterns of this.oauthRedirectHostCache.values()) {
381
+ for (const pattern of patterns) {
382
+ hosts.add(pattern);
383
+ }
384
+ }
385
+ return Array.from(hosts);
386
+ }
387
+ extractRedirectHostPatterns(oauthConfig, profileId) {
388
+ if (!oauthConfig?.redirect_uri) {
389
+ return [];
390
+ }
391
+ const resolvedRedirectUri = this.resolveRedirectUriFromEnv(oauthConfig.redirect_uri, profileId);
392
+ if (!resolvedRedirectUri) {
393
+ return [];
394
+ }
395
+ try {
396
+ const redirectUrl = new URL(resolvedRedirectUri);
397
+ return [redirectUrl.hostname];
398
+ }
399
+ catch {
400
+ return [];
401
+ }
402
+ }
403
+ resolveRedirectUriFromEnv(value, profileId) {
404
+ const match = value.match(/^\$\{env:([^}]+)\}$/);
405
+ if (!match) {
406
+ return value;
407
+ }
408
+ const envVar = match[1];
409
+ const envValue = process.env[envVar];
410
+ if (!envValue || envValue.trim().length === 0) {
411
+ const warningKey = `${profileId}:${envVar}`;
412
+ if (!this.warnedMissingOAuthRedirectEnvVars.has(warningKey)) {
413
+ this.warnedMissingOAuthRedirectEnvVars.add(warningKey);
414
+ this.logger.warn('OAuth redirect_uri environment variable is empty', {
415
+ profileId,
416
+ envVar,
417
+ });
418
+ }
419
+ return undefined;
420
+ }
421
+ return envValue;
422
+ }
423
+ resolveProfileIdFromPath(pathname) {
424
+ if (!this.config.profileRoutingEnabled) {
425
+ return null;
426
+ }
427
+ const match = /^\/profile\/([^/]+)/.exec(pathname);
428
+ if (!match) {
429
+ return null;
430
+ }
431
+ try {
432
+ return decodeURIComponent(match[1]);
433
+ }
434
+ catch {
435
+ return null;
436
+ }
437
+ }
438
+ getClientHintKey(req) {
439
+ const userAgent = req.get('user-agent') || '';
440
+ return `${req.ip}|${userAgent}`;
441
+ }
442
+ storeProfileHint(req, profileId) {
443
+ const key = this.getClientHintKey(req);
444
+ this.profileHintsByClient.set(key, { profileId, lastSeen: Date.now() });
445
+ }
446
+ resolveProfileIdFromHint(req) {
447
+ const key = this.getClientHintKey(req);
448
+ const hint = this.profileHintsByClient.get(key);
449
+ if (!hint) {
450
+ return null;
451
+ }
452
+ if (Date.now() - hint.lastSeen > HttpTransport.PROFILE_HINT_TTL_MS) {
453
+ this.profileHintsByClient.delete(key);
454
+ return null;
455
+ }
456
+ return hint.profileId;
457
+ }
458
+ resolveProfileIdForOriginCheck(req) {
459
+ const pathProfileId = this.resolveProfileIdFromPath(req.path);
460
+ if (pathProfileId) {
461
+ return pathProfileId;
462
+ }
463
+ const resource = req.query?.resource;
464
+ if (typeof resource === 'string') {
465
+ const info = this.resolveProfileInfoFromResourceUrl(resource);
466
+ if (info?.profileId) {
467
+ return info.profileId;
468
+ }
469
+ }
470
+ return this.getDefaultProfileId() ?? this.resolveProfileIdFromHint(req) ?? null;
471
+ }
472
+ async primeOAuthRedirectHosts(profileId) {
473
+ if (!this.profileContextProvider) {
474
+ return;
475
+ }
476
+ if (this.oauthRedirectHostCache.has(profileId)) {
477
+ return;
478
+ }
479
+ try {
480
+ const context = await this.profileContextProvider(profileId);
481
+ if (!context?.oauthConfig) {
482
+ this.oauthRedirectHostCache.set(profileId, []);
483
+ return;
484
+ }
485
+ const patterns = this.extractRedirectHostPatterns(context.oauthConfig, profileId);
486
+ this.oauthRedirectHostCache.set(profileId, patterns);
487
+ }
488
+ catch (error) {
489
+ if (error instanceof ConfigurationError && error.message === 'Profile not found') {
490
+ return;
491
+ }
492
+ this.logger.warn('Failed to preload OAuth redirect hosts', {
493
+ profileId,
494
+ error: String(error),
495
+ });
496
+ }
497
+ }
498
+ async isAllowedOriginForRequest(origin, req) {
499
+ if (this.isAllowedOrigin(origin)) {
500
+ return true;
501
+ }
502
+ if (!this.config.profileRoutingEnabled) {
503
+ return false;
504
+ }
505
+ const profileId = this.resolveProfileIdForOriginCheck(req);
506
+ if (!profileId) {
507
+ return false;
508
+ }
509
+ await this.primeOAuthRedirectHosts(profileId);
510
+ return this.isAllowedOrigin(origin);
511
+ }
512
+ /**
513
+ * Match hostname against allowed origin pattern
514
+ *
515
+ * Supports:
516
+ * - Exact match: 'example.com' === 'example.com'
517
+ * - Wildcard: '*.example.com' matches 'api.example.com', 'web.example.com'
518
+ * - CIDR: '192.168.1.0/24' matches '192.168.1.1' through '192.168.1.254'
519
+ */
520
+ matchOrigin(hostname, pattern) {
521
+ const normalizedHost = this.stripIpv6Brackets(hostname);
522
+ const normalizedPattern = this.stripIpv6Brackets(pattern);
523
+ // Exact match
524
+ if (normalizedHost === normalizedPattern) {
525
+ return true;
526
+ }
527
+ // Wildcard subdomain match (*.example.com)
528
+ if (normalizedPattern.startsWith('*.')) {
529
+ const domain = normalizedPattern.substring(2); // Remove '*.'
530
+ return normalizedHost.endsWith('.' + domain) || normalizedHost === domain;
531
+ }
532
+ // CIDR match (IPv4/IPv6)
533
+ if (normalizedPattern.includes('/')) {
534
+ return this.matchCIDR(normalizedHost, normalizedPattern);
535
+ }
536
+ return false;
537
+ }
538
+ /**
539
+ * Check if IP address is within CIDR range (IPv4 or IPv6)
540
+ *
541
+ * Example: '192.168.1.50' matches '192.168.1.0/24'
542
+ * '2001:db8::1' matches '2001:db8::/32'
543
+ */
544
+ matchCIDR(ip, cidr) {
545
+ const [rawRange, bits] = cidr.split('/');
546
+ const range = this.stripIpv6Brackets(rawRange);
547
+ const maskBits = parseInt(bits, 10);
548
+ if (isNaN(maskBits)) {
549
+ return false;
550
+ }
551
+ const ipVersion = isIP(ip);
552
+ const rangeVersion = isIP(range);
553
+ if (ipVersion === 0 || rangeVersion === 0 || ipVersion !== rangeVersion) {
554
+ return false;
555
+ }
556
+ if (ipVersion === 4) {
557
+ if (maskBits < 0 || maskBits > 32) {
558
+ this.logger.warn('Invalid CIDR mask bits', { cidr });
559
+ return false;
560
+ }
561
+ const ipInt = this.ipv4ToInt(ip);
562
+ const rangeInt = this.ipv4ToInt(range);
563
+ /* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
564
+ * This should never happen in practice, but serves as a fail-safe */
565
+ if (ipInt === null || rangeInt === null) {
566
+ return false;
567
+ }
568
+ /* c8 ignore end */
569
+ const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
570
+ return (ipInt & mask) === (rangeInt & mask);
571
+ }
572
+ if (maskBits < 0 || maskBits > 128) {
573
+ this.logger.warn('Invalid IPv6 CIDR mask bits', { cidr });
574
+ return false;
575
+ }
576
+ const ipInt = this.ipv6ToBigInt(ip);
577
+ const rangeInt = this.ipv6ToBigInt(range);
578
+ /* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
579
+ * This should never happen in practice, but serves as a fail-safe */
580
+ if (ipInt === null || rangeInt === null) {
581
+ return false;
582
+ }
583
+ /* c8 ignore end */
584
+ const mask = this.ipv6Mask(maskBits);
585
+ return (ipInt & mask) === (rangeInt & mask);
586
+ }
587
+ /**
588
+ * Convert IPv4 address to 32-bit integer
589
+ *
590
+ * Example: '192.168.1.1' -> 3232235777
591
+ */
592
+ ipv4ToInt(ip) {
593
+ const parts = ip.split('.');
594
+ if (parts.length !== 4) {
595
+ return null;
596
+ }
597
+ let result = 0;
598
+ for (let i = 0; i < 4; i++) {
599
+ const octet = parseInt(parts[i], 10);
600
+ if (isNaN(octet) || octet < 0 || octet > 255) {
601
+ return null;
602
+ }
603
+ result = (result << 8) | octet;
604
+ }
605
+ return result >>> 0; // Unsigned
606
+ }
607
+ /**
608
+ * Convert IPv6 address to 128-bit BigInt
609
+ */
610
+ ipv6ToBigInt(ip) {
611
+ const cleaned = this.stripIpv6Brackets(ip);
612
+ // Handle IPv4-mapped IPv6 (e.g., ::ffff:192.168.0.1)
613
+ let ipv4Tail = null;
614
+ let base = cleaned;
615
+ if (cleaned.includes('.')) {
616
+ const lastColon = cleaned.lastIndexOf(':');
617
+ if (lastColon === -1)
618
+ return null;
619
+ const ipv4Part = cleaned.slice(lastColon + 1);
620
+ ipv4Tail = this.ipv4ToInt(ipv4Part);
621
+ if (ipv4Tail === null)
622
+ return null;
623
+ base = cleaned.slice(0, lastColon);
624
+ }
625
+ const parts = base.split('::');
626
+ if (parts.length > 2) {
627
+ return null;
628
+ }
629
+ const head = parts[0] ? parts[0].split(':') : [];
630
+ const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
631
+ if (head.some(p => p === '') || tail.some(p => p === '')) {
632
+ return null;
633
+ }
634
+ const totalSegmentsNeeded = 8 - (ipv4Tail !== null ? 2 : 0);
635
+ let segments = [];
636
+ const parseHextets = (items) => {
637
+ const result = [];
638
+ for (const part of items) {
639
+ const num = parseInt(part || '0', 16);
640
+ if (isNaN(num) || num < 0 || num > 0xFFFF) {
641
+ return null;
642
+ }
643
+ result.push(num);
644
+ }
645
+ return result;
646
+ };
647
+ const headVals = parseHextets(head);
648
+ const tailVals = parseHextets(tail);
649
+ if (!headVals || !tailVals) {
650
+ return null;
651
+ }
652
+ const missing = totalSegmentsNeeded - (headVals.length + tailVals.length);
653
+ if (missing < 0) {
654
+ return null;
655
+ }
656
+ segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
657
+ /* c8 ignore start - defensive check that should never trigger if logic above is correct */
658
+ if (segments.length !== totalSegmentsNeeded) {
659
+ return null;
660
+ }
661
+ /* c8 ignore end */
662
+ if (ipv4Tail !== null) {
663
+ const high = (ipv4Tail >>> 16) & 0xFFFF;
664
+ const low = ipv4Tail & 0xFFFF;
665
+ segments.push(high, low);
666
+ }
667
+ /* c8 ignore start - defensive check that should never trigger if logic above is correct */
668
+ if (segments.length !== 8) {
669
+ return null;
670
+ }
671
+ /* c8 ignore end */
672
+ let value = 0n;
673
+ for (const part of segments) {
674
+ value = (value << 16n) + BigInt(part);
675
+ }
676
+ return value;
677
+ }
678
+ ipv6Mask(maskBits) {
679
+ if (maskBits === 0) {
680
+ return 0n;
681
+ }
682
+ const ones = (1n << BigInt(maskBits)) - 1n;
683
+ return BigInt.asUintN(128, ones << BigInt(128 - maskBits));
684
+ }
685
+ stripIpv6Brackets(value) {
686
+ return value.replace(/^\[/, '').replace(/\]$/, '');
687
+ }
688
+ /**
689
+ * Create configured rate limiter or a passthrough handler when disabled
690
+ *
691
+ * Why: Both MCP and metrics endpoints share the same rate limiting setup logic.
692
+ * Centralizing it keeps behaviour consistent and avoids drifting configuration.
693
+ */
694
+ createRateLimiter(options) {
695
+ if (!options.enabled) {
696
+ return (_req, _res, next) => next();
697
+ }
698
+ const message = options.responseMessage ??
699
+ `Rate limit exceeded. Max ${options.maxRequests} requests per ${options.windowMs / 1000} seconds.`;
700
+ return rateLimit({
701
+ windowMs: options.windowMs,
702
+ max: options.maxRequests,
703
+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
704
+ legacyHeaders: false, // Disable deprecated `X-RateLimit-*` headers
705
+ handler: (req, res) => {
706
+ this.logger.warn(options.logMessage, {
707
+ ip: req.ip,
708
+ path: req.path,
709
+ method: req.method,
710
+ });
711
+ res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({
712
+ error: 'Too Many Requests',
713
+ message,
714
+ });
715
+ },
716
+ });
717
+ }
718
+ formatRateLimitMessage(scope, maxRequests, windowMs) {
719
+ return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
720
+ }
721
+ getProfilePrefix(profileId, options) {
722
+ if (!this.config.profileRoutingEnabled) {
723
+ return '';
724
+ }
725
+ if (options?.forceProfilePrefix && profileId) {
726
+ return `/profile/${encodeURIComponent(profileId)}`;
727
+ }
728
+ const defaultProfileId = this.getDefaultProfileId();
729
+ if (!profileId || (defaultProfileId && profileId === defaultProfileId)) {
730
+ return '';
731
+ }
732
+ return `/profile/${encodeURIComponent(profileId)}`;
733
+ }
734
+ buildProfilePath(profileId, path, options) {
735
+ const prefix = this.getProfilePrefix(profileId, options);
736
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
737
+ return `${prefix}${normalizedPath}`;
738
+ }
739
+ getServerOrigin(profileId) {
740
+ if (profileId) {
741
+ const state = this.profileStates.get(profileId);
742
+ if (state?.oauthProvider?.redirectUri) {
743
+ try {
744
+ return new URL(state.oauthProvider.redirectUri).origin;
745
+ }
746
+ catch {
747
+ // Ignore invalid URL
748
+ }
749
+ }
750
+ }
751
+ const protocol = this.config.host.includes('://') ? '' : 'http://';
752
+ const host = this.config.host.includes('://') ? this.config.host : this.config.host;
753
+ return `${protocol}${host}:${this.config.port}`;
754
+ }
755
+ buildProfileUrl(profileId, path, options) {
756
+ return `${this.getServerOrigin(profileId)}${this.buildProfilePath(profileId, path, options)}`;
757
+ }
758
+ normalizeResourcePath(pathname) {
759
+ const normalized = pathname.replace(/\/+$/, '');
760
+ return normalized === '' ? '/' : normalized;
761
+ }
762
+ resolveProfileInfoFromResourceUrl(resource) {
763
+ let url;
764
+ try {
765
+ url = new URL(resource);
766
+ }
767
+ catch {
768
+ return null;
769
+ }
770
+ const path = this.normalizeResourcePath(url.pathname);
771
+ if (path === '/mcp') {
772
+ const defaultProfileId = this.getDefaultProfileId();
773
+ if (!defaultProfileId) {
774
+ return null;
775
+ }
776
+ return { profileId: defaultProfileId, forceProfilePrefix: false };
777
+ }
778
+ if (!this.config.profileRoutingEnabled) {
779
+ return null;
780
+ }
781
+ const match = /^\/profile\/([^/]+)\/mcp$/.exec(path);
782
+ if (!match) {
783
+ return null;
784
+ }
785
+ try {
786
+ return { profileId: decodeURIComponent(match[1]), forceProfilePrefix: true };
787
+ }
788
+ catch {
789
+ return null;
790
+ }
791
+ }
792
+ resolveProfileIdFromResourceUrl(resource) {
793
+ return this.resolveProfileInfoFromResourceUrl(resource)?.profileId ?? null;
794
+ }
795
+ getOAuthProtectedResourceUrl(profileId) {
796
+ const effectiveProfileId = profileId ?? this.getDefaultProfileId();
797
+ return this.buildProfileUrl(effectiveProfileId, OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE);
798
+ }
799
+ respondProfileNotFound(res, profileId) {
800
+ res.status(HTTP_STATUS.NOT_FOUND).json({
801
+ error: 'Not Found',
802
+ message: profileId ? `Profile '${profileId}' not found` : 'Profile not found',
803
+ });
804
+ }
805
+ /**
806
+ * Setup MCP endpoint routes
807
+ *
808
+ * Why: Single endpoint for POST (client→server) and GET (SSE stream)
809
+ */
810
+ setupRoutes() {
811
+ this.logger.info('Setting up HTTP routes');
812
+ // Security: Rate limiting setup (needed for OAuth routes)
813
+ const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
814
+ const profileRoutingEnabled = this.config.profileRoutingEnabled === true;
815
+ const defaultProfileId = this.getDefaultProfileId();
816
+ const attachProfileId = (req, _res, next) => {
817
+ req.profileId = req.params.profileId;
818
+ next();
819
+ };
820
+ const oauthRateLimiterByProfile = new Map();
821
+ const getOAuthRateLimiter = (profileState) => {
822
+ const existing = oauthRateLimiterByProfile.get(profileState.profileId);
823
+ if (existing) {
824
+ return existing;
825
+ }
826
+ const oauthWindowMs = profileState.context.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
827
+ const oauthMaxRequests = profileState.context.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
828
+ const limiter = this.createRateLimiter({
829
+ enabled: rateLimitEnabled,
830
+ windowMs: oauthWindowMs,
831
+ maxRequests: oauthMaxRequests,
832
+ logMessage: 'Rate limit exceeded for OAuth',
833
+ responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
834
+ });
835
+ oauthRateLimiterByProfile.set(profileState.profileId, limiter);
836
+ return limiter;
837
+ };
838
+ const oauthRateLimiter = async (req, res, next) => {
839
+ const profileState = await this.getProfileStateForRequest(req);
840
+ if (!profileState) {
841
+ this.respondProfileNotFound(res, req.profileId);
842
+ return;
843
+ }
844
+ const limiter = getOAuthRateLimiter(profileState);
845
+ // codeql[js/unvalidated-dynamic-method-call] limiter is a RequestHandler from createRateLimiter.
846
+ return limiter(req, res, next);
847
+ };
848
+ const withProfileState = (handler) => {
849
+ return async (req, res) => {
850
+ const profileState = await this.getProfileStateForRequest(req);
851
+ if (!profileState) {
852
+ this.respondProfileNotFound(res, req.profileId);
853
+ return;
854
+ }
855
+ await handler(req, res, profileState);
856
+ };
857
+ };
858
+ const registerOAuthRoutes = (basePath, includeProfileParam, includeProtectedResource) => {
859
+ const middlewares = includeProfileParam ? [attachProfileId] : [];
860
+ const withOAuthRateLimit = [...middlewares, oauthRateLimiter];
861
+ const withProfile = (handler) => {
862
+ return [...middlewares, oauthRateLimiter, withProfileState(handler)];
863
+ };
864
+ if (includeProtectedResource) {
865
+ this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE}`, ...withProfile((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
866
+ }
867
+ this.app.get(`${basePath}${OAUTH_PATHS.AUTHORIZE}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
868
+ this.app.post(`${basePath}${OAUTH_PATHS.TOKEN}`, ...middlewares, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
869
+ this.app.get(`${basePath}${OAUTH_PATHS.CALLBACK}`, ...withProfile((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
870
+ this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
871
+ this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
872
+ this.app.post(`${basePath}${OAUTH_PATHS.REGISTER}`, ...middlewares, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
873
+ this.logger.info('OAuth routes registered', {
874
+ basePath,
875
+ profileRoutingEnabled,
876
+ });
877
+ };
878
+ if (defaultProfileId) {
879
+ registerOAuthRoutes('', false, false);
880
+ }
881
+ if (profileRoutingEnabled) {
882
+ registerOAuthRoutes('/profile/:profileId', true, true);
883
+ }
884
+ const attachProfileFromResourceQuery = (req, res, next) => {
885
+ const { resource } = req.query;
886
+ if (resource === undefined) {
887
+ next();
888
+ return;
889
+ }
890
+ if (typeof resource !== 'string') {
891
+ res.status(HTTP_STATUS.BAD_REQUEST).json({
892
+ error: 'invalid_request',
893
+ message: 'Invalid resource query parameter',
894
+ });
895
+ return;
896
+ }
897
+ const info = this.resolveProfileInfoFromResourceUrl(resource);
898
+ if (!info?.profileId) {
899
+ res.status(HTTP_STATUS.NOT_FOUND).json({
900
+ error: 'Not Found',
901
+ message: 'OAuth metadata unavailable for requested resource',
902
+ });
903
+ return;
904
+ }
905
+ req.profileId = info.profileId;
906
+ req.forceProfilePrefix = info.forceProfilePrefix;
907
+ this.storeProfileHint(req, info.profileId);
908
+ next();
909
+ };
910
+ const attachProfileFromHint = (req, res, next) => {
911
+ const profileId = this.resolveProfileIdFromHint(req);
912
+ if (!profileId) {
913
+ res.status(HTTP_STATUS.NOT_FOUND).json({
914
+ error: 'Not Found',
915
+ message: 'OAuth metadata unavailable for requested resource',
916
+ });
917
+ return;
918
+ }
919
+ req.profileId = profileId;
920
+ req.forceProfilePrefix = true;
921
+ next();
922
+ };
923
+ if (defaultProfileId || profileRoutingEnabled) {
924
+ this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
925
+ }
926
+ if (profileRoutingEnabled) {
927
+ this.app.get('/.well-known/oauth-protected-resource', attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
928
+ this.app.get('/.well-known/oauth-protected-resource/profile/:profileId/mcp', attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
929
+ if (!defaultProfileId) {
930
+ this.app.get(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
931
+ this.app.get(OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
932
+ this.app.post(OAUTH_PATHS.REGISTER, attachProfileFromHint, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
933
+ this.app.get(OAUTH_PATHS.AUTHORIZE, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
934
+ this.app.post(OAUTH_PATHS.TOKEN, attachProfileFromHint, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
935
+ this.app.get(OAUTH_PATHS.CALLBACK, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
936
+ }
937
+ this.app.get(`${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}/profile/:profileId`, attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
938
+ this.app.get(`${OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION}/profile/:profileId`, attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
939
+ }
940
+ // Security: Rate limiting setup (for MCP endpoints)
941
+ const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
942
+ const maxRequests = this.config.rateLimitMaxRequests || 100; // 100 req/min
943
+ const metricsMaxRequests = this.config.rateLimitMetricsMax || 10; // 10 req/min for metrics
944
+ if (rateLimitEnabled) {
945
+ this.logger.info('Rate limiting enabled', {
946
+ windowMs,
947
+ maxRequests,
948
+ metricsMaxRequests,
949
+ });
950
+ }
951
+ // Rate limiter for MCP/SSE endpoints (100 req/min by default)
952
+ const mcpRateLimiter = this.createRateLimiter({
953
+ enabled: rateLimitEnabled,
954
+ windowMs,
955
+ maxRequests,
956
+ logMessage: 'Rate limit exceeded',
957
+ });
958
+ // Rate limiter for metrics endpoint (10 req/min by default)
959
+ const metricsRateLimiter = this.createRateLimiter({
960
+ enabled: rateLimitEnabled,
961
+ windowMs,
962
+ maxRequests: metricsMaxRequests,
963
+ logMessage: 'Rate limit exceeded for metrics',
964
+ responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
965
+ });
966
+ const registerMcpRoutes = (basePath, includeProfileParam, isDefault) => {
967
+ const middlewares = includeProfileParam ? [attachProfileId] : [];
968
+ const pathPrefix = basePath || '';
969
+ // Main MCP endpoint - POST for sending messages
970
+ this.app.post(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handlePost.bind(this));
971
+ // CORS preflight handler
972
+ this.app.options(`${pathPrefix}/mcp`, ...middlewares, async (req, res) => {
973
+ const origin = req.headers.origin;
974
+ try {
975
+ // Only send CORS headers for explicitly allowed origins; otherwise reject
976
+ if (origin && await this.isAllowedOriginForRequest(origin, req)) {
977
+ // nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
978
+ res.setHeader('Access-Control-Allow-Origin', origin);
979
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
980
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
981
+ // We do not allow credentials; prevents cookie-based attacks by default
982
+ res.setHeader('Access-Control-Allow-Credentials', 'false');
983
+ res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache
984
+ return res.status(HTTP_STATUS.OK).send();
985
+ }
986
+ // Disallowed origin: do not echo origin or emit permissive headers
987
+ res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
988
+ }
989
+ catch (error) {
990
+ this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
991
+ res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
992
+ }
993
+ });
994
+ // Main MCP endpoint - GET for SSE streaming
995
+ this.app.get(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleGet.bind(this));
996
+ // Session termination
997
+ this.app.delete(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleDelete.bind(this));
998
+ // Legacy alias endpoints - deprecated
999
+ // Why: Backward compatibility for clients using /sse during migration
1000
+ this.app.post(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
1001
+ this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
1002
+ this.logger.info('Handling POST /sse request');
1003
+ return this.handlePost(req, res, next);
1004
+ });
1005
+ this.app.get(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
1006
+ this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
1007
+ this.logger.info(`Handling GET /sse request from: ${req.ip}`);
1008
+ return this.handleGet(req, res, next);
1009
+ });
1010
+ this.app.delete(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
1011
+ this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
1012
+ return this.handleDelete(req, res, next);
1013
+ });
1014
+ if (isDefault) {
1015
+ this.logger.info('Registered MCP routes for default profile', { pathPrefix: pathPrefix || '/mcp' });
1016
+ }
1017
+ else {
1018
+ this.logger.info('Registered MCP routes for profile routing', { pathPrefix: `${pathPrefix}/mcp` });
1019
+ }
1020
+ };
1021
+ if (defaultProfileId) {
1022
+ registerMcpRoutes('', false, true);
1023
+ }
1024
+ if (profileRoutingEnabled) {
1025
+ registerMcpRoutes('/profile/:profileId', true, false);
1026
+ }
1027
+ // Metrics endpoint (if enabled)
1028
+ if (this.config.metricsEnabled) {
1029
+ this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
1030
+ }
1031
+ // Health check (with rate limiting)
1032
+ this.app.get('/health', mcpRateLimiter, (req, res) => {
1033
+ const startTime = Date.now();
1034
+ let totalSessions = 0;
1035
+ for (const state of this.profileStates.values()) {
1036
+ totalSessions += state.sessions.size;
1037
+ }
1038
+ res.json({ status: 'ok', sessions: totalSessions });
1039
+ if (this.metrics) {
1040
+ const duration = (Date.now() - startTime) / 1000;
1041
+ this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
1042
+ }
1043
+ });
1044
+ if (this.config.profileIndexEnabled) {
1045
+ this.app.get('/', async (req, res) => {
1046
+ await this.handleProfileIndex(req, res);
1047
+ });
1048
+ }
1049
+ // Debug: SSE route registered
1050
+ this.logger.info('SSE routes registered successfully');
1051
+ // Default 404 handler - MUST be last route registered
1052
+ // This will catch all unmatched requests
1053
+ this.app.use((req, res) => {
1054
+ this.logger.warn('Unhandled request (404)', {
1055
+ method: req.method,
1056
+ url: req.url,
1057
+ path: req.path,
1058
+ headers: req.headers,
1059
+ ip: req.ip
1060
+ });
1061
+ res.status(HTTP_STATUS.NOT_FOUND).json({
1062
+ error: 'Not Found',
1063
+ message: `Endpoint ${req.method} ${req.path} not found`
1064
+ });
1065
+ });
1066
+ }
1067
+ getProfileIssuerUrl(profileId, options) {
1068
+ const origin = this.getServerOrigin(profileId);
1069
+ const prefix = this.getProfilePrefix(profileId, options);
1070
+ return `${origin}${prefix}`;
1071
+ }
1072
+ getRequestOrigin(req) {
1073
+ const hostHeader = req.get('host');
1074
+ if (hostHeader) {
1075
+ return `${req.protocol}://${hostHeader}`;
1076
+ }
1077
+ return this.getServerOrigin();
1078
+ }
1079
+ async handleProfileIndex(req, res) {
1080
+ if (!this.config.profileRoutingEnabled || !this.config.profileIndexEnabled) {
1081
+ res.status(HTTP_STATUS.NOT_FOUND).json({
1082
+ error: 'Not Found',
1083
+ message: 'Endpoint GET / not found',
1084
+ });
1085
+ return;
1086
+ }
1087
+ if (!this.profileIndexProvider) {
1088
+ res.status(HTTP_STATUS.NOT_FOUND).json({
1089
+ error: 'Not Found',
1090
+ message: 'Profile index not available',
1091
+ });
1092
+ return;
1093
+ }
1094
+ const accept = req.headers.accept || '';
1095
+ const prefersJson = accept.includes('application/json') && !accept.includes('text/html');
1096
+ const locale = parseAcceptLanguage(req.headers['accept-language']);
1097
+ let profiles;
1098
+ try {
1099
+ profiles = await this.profileIndexProvider();
1100
+ }
1101
+ catch (error) {
1102
+ this.logger.error('Failed to load profile index', error instanceof Error ? error : new Error(String(error)));
1103
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
1104
+ error: 'Internal Server Error',
1105
+ message: 'Failed to load profile index',
1106
+ });
1107
+ return;
1108
+ }
1109
+ const origin = this.getRequestOrigin(req);
1110
+ const { payload, templateData } = buildProfileIndexPayload(profiles, origin, locale);
1111
+ if (prefersJson) {
1112
+ res.json(payload);
1113
+ return;
1114
+ }
1115
+ const template = await loadProfileIndexTemplate();
1116
+ const nonce = crypto.randomBytes(16).toString('base64');
1117
+ const html = renderProfileIndexHtml(template, templateData, nonce);
1118
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1119
+ res.setHeader('Cache-Control', 'no-store');
1120
+ res.setHeader('Content-Security-Policy', `default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`);
1121
+ res.send(html);
1122
+ }
1123
+ async handleOAuthProtectedResource(req, res, profileState) {
1124
+ if (!profileState.oauthProvider) {
1125
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'OAuth not configured for this profile' });
1126
+ return;
1127
+ }
1128
+ const profileId = profileState.profileId;
1129
+ const isProfileScoped = typeof req.params?.profileId === 'string';
1130
+ const forceProfilePrefix = isProfileScoped || req.forceProfilePrefix === true;
1131
+ const urlOptions = forceProfilePrefix ? { forceProfilePrefix: true } : undefined;
1132
+ const serverUrl = new URL(this.buildProfileUrl(profileId, '/mcp', urlOptions));
1133
+ const issuerUrl = this.getProfileIssuerUrl(profileId, urlOptions);
1134
+ const metadata = {
1135
+ resource: serverUrl.href,
1136
+ authorization_servers: [issuerUrl],
1137
+ bearer_methods_supported: ['header'],
1138
+ };
1139
+ if (profileState.oauthProvider.scopes && profileState.oauthProvider.scopes.length > 0) {
1140
+ metadata.scopes_supported = profileState.oauthProvider.scopes;
1141
+ }
1142
+ if (profileState.context.resourceName) {
1143
+ metadata.resource_name = profileState.context.resourceName;
1144
+ }
1145
+ if (profileState.context.resourceDocumentation) {
1146
+ metadata.resource_documentation = profileState.context.resourceDocumentation;
1147
+ }
1148
+ res.json(metadata);
1149
+ }
1150
+ async handleOAuthAuthorize(req, res, profileState) {
1151
+ if (!profileState.oauthProvider) {
1152
+ res.status(HTTP_STATUS.NOT_FOUND).send('OAuth not configured for this profile');
1153
+ return;
1154
+ }
1155
+ try {
1156
+ res.setHeader('Cache-Control', 'no-store');
1157
+ const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
1158
+ if (!client_id || typeof client_id !== 'string') {
1159
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
1160
+ return;
1161
+ }
1162
+ if (!response_type || typeof response_type !== 'string') {
1163
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing response_type');
1164
+ return;
1165
+ }
1166
+ if (response_type !== 'code') {
1167
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Unsupported response_type');
1168
+ return;
1169
+ }
1170
+ if (!redirect_uri || typeof redirect_uri !== 'string') {
1171
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
1172
+ return;
1173
+ }
1174
+ await profileState.oauthProvider.ensureEndpointsInitialized();
1175
+ const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
1176
+ if (!client) {
1177
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
1178
+ return;
1179
+ }
1180
+ const scopeStr = (scope || '').trim();
1181
+ const params = {
1182
+ responseType: response_type,
1183
+ clientId: client_id,
1184
+ redirectUri: redirect_uri,
1185
+ scope: scopeStr ? scopeStr.split(' ') : [],
1186
+ state: state,
1187
+ codeChallenge: code_challenge,
1188
+ codeChallengeMethod: code_challenge_method,
1189
+ scopes: scopeStr ? scopeStr.split(' ') : [],
1190
+ };
1191
+ await profileState.oauthProvider.authorize(client, params, res);
1192
+ }
1193
+ catch (error) {
1194
+ this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
1195
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
1196
+ }
1197
+ }
1198
+ async handleOAuthToken(req, res, profileState) {
1199
+ if (!profileState.oauthProvider) {
1200
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
1201
+ return;
1202
+ }
1203
+ try {
1204
+ res.setHeader('Cache-Control', 'no-store');
1205
+ const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
1206
+ this.logger.debug('OAuth token request', {
1207
+ profileId: profileState.profileId,
1208
+ grant_type,
1209
+ client_id,
1210
+ has_code: !!code,
1211
+ has_code_verifier: !!code_verifier,
1212
+ redirect_uri,
1213
+ });
1214
+ if (grant_type === 'authorization_code') {
1215
+ if (!code) {
1216
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
1217
+ return;
1218
+ }
1219
+ await profileState.oauthProvider.ensureEndpointsInitialized();
1220
+ const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
1221
+ if (!client) {
1222
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
1223
+ return;
1224
+ }
1225
+ const tokens = await profileState.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
1226
+ this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
1227
+ res.json(tokens);
1228
+ return;
1229
+ }
1230
+ if (grant_type === 'refresh_token') {
1231
+ if (!refresh_token) {
1232
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
1233
+ return;
1234
+ }
1235
+ await profileState.oauthProvider.ensureEndpointsInitialized();
1236
+ const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
1237
+ if (!client) {
1238
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
1239
+ return;
1240
+ }
1241
+ const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, refresh_token);
1242
+ this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
1243
+ res.json(tokens);
1244
+ return;
1245
+ }
1246
+ this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
1247
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
1248
+ }
1249
+ catch (error) {
1250
+ this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
1251
+ res.status(HTTP_STATUS.BAD_REQUEST).json({
1252
+ error: 'invalid_grant',
1253
+ error_description: 'Token exchange failed',
1254
+ });
1255
+ }
1256
+ }
1257
+ async handleOAuthCallback(req, res, profileState) {
1258
+ if (!profileState.oauthProvider) {
1259
+ res.status(HTTP_STATUS.NOT_FOUND).send('OAuth provider not initialized');
1260
+ return;
1261
+ }
1262
+ try {
1263
+ const { code, state, error, error_description } = req.query;
1264
+ this.logger.info('OAuth callback received', {
1265
+ profileId: profileState.profileId,
1266
+ hasCode: !!code,
1267
+ hasState: !!state,
1268
+ error: error,
1269
+ errorDescription: error_description,
1270
+ });
1271
+ if (error) {
1272
+ const safeError = escapeHtmlSafe(error);
1273
+ const safeErrorDesc = escapeHtmlSafe(error_description);
1274
+ res.status(HTTP_STATUS.BAD_REQUEST).json({
1275
+ error: safeError,
1276
+ error_description: safeErrorDesc || safeError,
1277
+ });
1278
+ return;
1279
+ }
1280
+ if (!code || typeof code !== 'string') {
1281
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
1282
+ return;
1283
+ }
1284
+ await profileState.oauthProvider.handleCallback(req, res);
1285
+ }
1286
+ catch (error) {
1287
+ this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
1288
+ if (!res.headersSent) {
1289
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
1290
+ }
1291
+ }
1292
+ }
1293
+ async handleOAuthAuthorizationServerMetadata(req, res, profileState) {
1294
+ if (!profileState.oauthProvider) {
1295
+ res.status(HTTP_STATUS.NOT_FOUND).send('OAuth metadata unavailable');
1296
+ return;
1297
+ }
1298
+ try {
1299
+ const profileId = profileState.profileId;
1300
+ const isProfileScoped = typeof req.params?.profileId === 'string';
1301
+ const forceProfilePrefix = isProfileScoped || req.forceProfilePrefix === true;
1302
+ const urlOptions = forceProfilePrefix ? { forceProfilePrefix: true } : undefined;
1303
+ const issuer = this.getProfileIssuerUrl(profileId, urlOptions);
1304
+ res.json({
1305
+ issuer,
1306
+ authorization_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.AUTHORIZE, urlOptions),
1307
+ token_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.TOKEN, urlOptions),
1308
+ registration_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.REGISTER, urlOptions),
1309
+ response_types_supported: ['code'],
1310
+ code_challenge_methods_supported: ['S256'],
1311
+ grant_types_supported: ['authorization_code', 'refresh_token'],
1312
+ scopes_supported: profileState.oauthProvider.scopes || ['api'],
1313
+ });
1314
+ }
1315
+ catch (error) {
1316
+ this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
1317
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
1318
+ }
1319
+ }
1320
+ async handleOAuthRegister(req, res, profileState) {
1321
+ if (!profileState.oauthProvider) {
1322
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'Registration unavailable' });
1323
+ return;
1324
+ }
1325
+ try {
1326
+ const { redirect_uris } = req.body;
1327
+ this.logger.info('Dynamic client registration request', {
1328
+ profileId: profileState.profileId,
1329
+ redirect_uris,
1330
+ });
1331
+ const clientId = 'mcp-proxy-client';
1332
+ const clientSecret = 'mcp-proxy-secret';
1333
+ const client = {
1334
+ client_id: clientId,
1335
+ client_secret: clientSecret,
1336
+ redirect_uris: redirect_uris || [],
1337
+ grant_types: ['authorization_code', 'refresh_token'],
1338
+ response_types: ['code'],
1339
+ scope: (profileState.oauthProvider.scopes || []).join(' '),
1340
+ };
1341
+ await profileState.oauthProvider.clientsStore.registerClient(client);
1342
+ res.status(HTTP_STATUS.CREATED).json({
1343
+ client_id: clientId,
1344
+ client_secret: clientSecret,
1345
+ redirect_uris: redirect_uris,
1346
+ grant_types: ['authorization_code', 'refresh_token'],
1347
+ response_types: ['code'],
1348
+ scope: (profileState.oauthProvider.scopes || []).join(' '),
1349
+ token_endpoint_auth_method: 'client_secret_post',
1350
+ });
1351
+ }
1352
+ catch (error) {
1353
+ this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
1354
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
1355
+ }
1356
+ }
1357
+ /**
1358
+ * Handle metrics endpoint
1359
+ *
1360
+ * Why: Prometheus scraping endpoint
1361
+ */
1362
+ async handleMetrics(req, res) {
1363
+ const startTime = Date.now();
1364
+ try {
1365
+ if (!this.metrics) {
1366
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Metrics disabled' });
1367
+ return;
1368
+ }
1369
+ const metrics = await this.metrics.getMetrics();
1370
+ res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
1371
+ res.send(metrics);
1372
+ // Don't record metrics call in metrics (avoid recursion)
1373
+ }
1374
+ catch (error) {
1375
+ const correlationId = generateCorrelationId();
1376
+ this.logger.error('Metrics endpoint error', error, { correlationId });
1377
+ res.setHeader('Cache-Control', 'no-store');
1378
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
1379
+ error: 'Internal Server Error',
1380
+ message: `Internal error (correlation ID: ${correlationId})`,
1381
+ correlationId
1382
+ });
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Validate authentication token by making a probe request to the API
1387
+ *
1388
+ * Supports all auth types: bearer, query, custom-header
1389
+ * Returns true if token is valid, false otherwise
1390
+ */
1391
+ /**
1392
+ * Builds a URL by intelligently combining base URL and endpoint
1393
+ * Handles absolute URLs, absolute paths, and relative paths correctly
1394
+ */
1395
+ buildUrl(endpoint, baseUrl) {
1396
+ // If endpoint is already an absolute URL, use it as-is
1397
+ if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
1398
+ return new URL(endpoint);
1399
+ }
1400
+ // If endpoint is an absolute path (starts with /), combine with origin of baseUrl
1401
+ if (endpoint.startsWith('/')) {
1402
+ const baseUrlObj = new URL(baseUrl);
1403
+ return new URL(endpoint, baseUrlObj.origin);
1404
+ }
1405
+ // Otherwise, treat as relative path and append to baseUrl
1406
+ // Ensure baseUrl ends with '/' for proper URL construction
1407
+ const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
1408
+ return new URL(endpoint, normalizedBaseUrl);
1409
+ }
1410
+ async validateAuthToken(authConfig, token, baseUrl) {
1411
+ if (!authConfig.validation_endpoint) {
1412
+ return true; // Skip validation if not configured
1413
+ }
1414
+ const url = this.buildUrl(authConfig.validation_endpoint, baseUrl);
1415
+ const headers = {};
1416
+ const method = authConfig.validation_method || 'GET';
1417
+ const timeout = authConfig.validation_timeout_ms || 5000;
1418
+ const urlString = url.toString();
1419
+ // Apply auth based on type
1420
+ switch (authConfig.type) {
1421
+ case 'oauth':
1422
+ case 'bearer':
1423
+ headers['Authorization'] = `Bearer ${token}`;
1424
+ break;
1425
+ case 'custom-header':
1426
+ if (authConfig.header_name) {
1427
+ headers[authConfig.header_name] = token;
1428
+ }
1429
+ break;
1430
+ case 'query':
1431
+ if (authConfig.query_param) {
1432
+ url.searchParams.set(authConfig.query_param, token);
1433
+ }
1434
+ break;
1435
+ }
1436
+ try {
1437
+ this.logger.debug('Validating auth token', {
1438
+ endpoint: urlString,
1439
+ method,
1440
+ authType: authConfig.type,
1441
+ });
1442
+ const controller = new AbortController();
1443
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1444
+ const response = await fetch(url.toString(), {
1445
+ method,
1446
+ headers,
1447
+ signal: controller.signal,
1448
+ });
1449
+ clearTimeout(timeoutId);
1450
+ const isValid = response.status >= 200 && response.status < 300;
1451
+ this.logger.debug('Auth token validation result', {
1452
+ status: response.status,
1453
+ isValid,
1454
+ });
1455
+ return isValid;
1456
+ }
1457
+ catch (error) {
1458
+ this.logger.warn(`Auth token validation failed: ${error.message}`, {
1459
+ endpoint: authConfig.validation_endpoint,
1460
+ });
1461
+ return false;
1462
+ }
1463
+ }
1464
+ /**
1465
+ * Validate token format and length
1466
+ *
1467
+ * Why centralized: Single source of truth for token validation rules
1468
+ *
1469
+ * Relaxed validation: Allow common API token characters including colons,
1470
+ * to support various token formats (GitLab glpat-, YouTrack perm:, etc.)
1471
+ */
1472
+ validateToken(token, source) {
1473
+ const maxLength = this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH;
1474
+ if (token.length > maxLength) {
1475
+ throw new ValidationError(`${source} too long (max ${maxLength} characters)`);
1476
+ }
1477
+ if (token.length === 0) {
1478
+ throw new ValidationError(`${source} is empty`);
1479
+ }
1480
+ // RFC 6750 Bearer token characters + common API token chars (including colons for YouTrack)
1481
+ // Allow: alphanumeric, dash, underscore, dot, tilde, plus, slash, equals, colon
1482
+ // Note: dash at end of character class to avoid being interpreted as range
1483
+ if (!/^[A-Za-z0-9._~+/:=-]+$/.test(token)) {
1484
+ throw new ValidationError(`Invalid ${source} format`);
1485
+ }
1486
+ }
1487
+ /**
1488
+ * Extract and validate auth token from request headers
1489
+ *
1490
+ * Supports:
1491
+ * - Authorization: Bearer <token>
1492
+ * - X-API-Token: <token>
1493
+ * - OAuth session (via mcp-session-id header)
1494
+ *
1495
+ * Why strict validation: Prevents header injection attacks
1496
+ *
1497
+ * Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
1498
+ */
1499
+ extractAuthToken(req, profileState) {
1500
+ const sessionId = req.sessionId || req.headers['mcp-session-id'];
1501
+ const session = sessionId ? profileState.sessions.get(sessionId) : undefined;
1502
+ const authConfigs = profileState.context.authConfigs;
1503
+ const configs = authConfigs ? (Array.isArray(authConfigs) ? authConfigs : [authConfigs]) : [];
1504
+ const hasOAuth = !!profileState.oauthProvider || configs.some(config => config.type === 'oauth');
1505
+ // 1. Check Authorization: Bearer header
1506
+ const authHeader = req.headers.authorization;
1507
+ if (authHeader) {
1508
+ // Defense against ReDoS: Check length before regex
1509
+ const maxHeaderLength = (this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH) + 10; // Bearer + spaces + margin
1510
+ if (authHeader.length > maxHeaderLength) {
1511
+ throw new ValidationError(`Authorization header too long (max ${maxHeaderLength} characters)`);
1512
+ }
1513
+ // Relaxed Bearer token format validation - allow flexible whitespace
1514
+ // Trim whitespace to handle client variations (IntelliJ, VSCode, etc.)
1515
+ const trimmed = authHeader.trim();
1516
+ const match = trimmed.match(/^Bearer\s+(.+)$/);
1517
+ if (!match) {
1518
+ throw new ValidationError('Invalid Authorization header format. Expected: Bearer <token>');
1519
+ }
1520
+ const token = match[1].trim();
1521
+ this.validateToken(token, 'Authorization token');
1522
+ return { type: 'bearer', token };
1523
+ }
1524
+ // 2. Check configured custom header (if any)
1525
+ if (configs.length > 0) {
1526
+ const sortedConfigs = configs.sort((a, b) => (a.priority || 0) - (b.priority || 0));
1527
+ const customHeaderConfig = sortedConfigs.find(c => c.type === 'custom-header' && c.header_name);
1528
+ if (customHeaderConfig && customHeaderConfig.header_name) {
1529
+ if (!isSafePropertyName(customHeaderConfig.header_name)) {
1530
+ throw new ValidationError(`Invalid custom auth header name: ${customHeaderConfig.header_name}`);
1531
+ }
1532
+ const headerKey = customHeaderConfig.header_name.toLowerCase();
1533
+ const headerValue = req.headers[headerKey];
1534
+ if (headerValue) {
1535
+ if (typeof headerValue !== 'string') {
1536
+ throw new ValidationError(`${customHeaderConfig.header_name} must be a string`);
1537
+ }
1538
+ this.validateToken(headerValue, customHeaderConfig.header_name);
1539
+ return { type: 'api-token', token: headerValue };
1540
+ }
1541
+ }
1542
+ }
1543
+ // 3. Check X-API-Token header (for custom implementations)
1544
+ const apiTokenHeader = req.headers['x-api-token'];
1545
+ if (apiTokenHeader) {
1546
+ if (typeof apiTokenHeader !== 'string') {
1547
+ throw new ValidationError('X-API-Token must be a string');
1548
+ }
1549
+ this.validateToken(apiTokenHeader, 'X-API-Token');
1550
+ return { type: 'api-token', token: apiTokenHeader };
1551
+ }
1552
+ // 4. Fall back to session token (OAuth only when configured)
1553
+ if (session && session.authToken) {
1554
+ return { type: hasOAuth ? 'oauth' : 'api-token', token: session.authToken, sessionId };
1555
+ }
1556
+ return { type: 'none' };
1557
+ }
1558
+ /**
1559
+ * Lazy initialization of ToolFilterService
1560
+ */
1561
+ getToolFilterService(profileState) {
1562
+ if (!profileState.toolFilterService) {
1563
+ const validator = new RegexValidator();
1564
+ const compiler = new RegexCompiler(validator);
1565
+ const envParser = new EnvConfigParser(compiler);
1566
+ const headerParser = new HeaderConfigParser(compiler);
1567
+ // Create OperationDetector for category filtering (if parser available)
1568
+ let detector;
1569
+ if (profileState.context.parser) {
1570
+ const classifier = new OperationClassifier();
1571
+ const resolver = new OpenAPIOperationResolver(profileState.context.parser);
1572
+ detector = new OperationDetector(classifier, resolver);
1573
+ }
1574
+ profileState.toolFilterService = new ToolFilterService(envParser, headerParser, this.logger, detector);
1575
+ }
1576
+ return profileState.toolFilterService;
1577
+ }
1578
+ /**
1579
+ * Handle POST requests - Client sending messages to server
1580
+ *
1581
+ * MCP Spec: POST can contain requests, notifications, or responses
1582
+ */
1583
+ async handlePost(req, res) {
1584
+ const startTime = Date.now();
1585
+ try {
1586
+ this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
1587
+ const profileState = await this.getProfileStateForRequest(req);
1588
+ if (!profileState) {
1589
+ this.respondProfileNotFound(res, req.profileId);
1590
+ return;
1591
+ }
1592
+ const requestProfileId = req.profileId ?? profileState.profileId;
1593
+ const sessionId = req.sessionId;
1594
+ const body = req.body;
1595
+ const filteringHeader = normalizeFilteringHeaderValue(this.getFilteringHeaderValue(req));
1596
+ const parsedFiltering = filteringHeader ? parseFilteringHeader(filteringHeader) : undefined;
1597
+ const toolFilterHeader = normalizeToolFilterHeaderValue(this.getToolFilterHeaderValue(req));
1598
+ const parsedToolFilter = toolFilterHeader !== undefined ? parseSessionToolFilterHeader(toolFilterHeader) : undefined;
1599
+ const normalizedToolFilterHeader = parsedToolFilter?.normalizedHeader;
1600
+ // Validate Accept header per MCP Streamable HTTP specification
1601
+ const accept = req.headers.accept || '';
1602
+ // POST requests can return either JSON or SSE, so must accept both if specified
1603
+ // GET requests return SSE, so must accept text/event-stream
1604
+ const acceptsJson = accept.includes(MIME_TYPES.JSON) || accept === '*/*' || accept === '';
1605
+ const acceptsEventStream = accept.includes(MIME_TYPES.EVENT_STREAM) || accept === '*/*' || accept === '';
1606
+ if (req.method === 'GET' && accept && !acceptsEventStream) {
1607
+ this.logger.debug('Accept header validation failed for GET, returning 406');
1608
+ res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
1609
+ error: 'Not Acceptable',
1610
+ message: `GET requests must accept ${MIME_TYPES.EVENT_STREAM}`
1611
+ });
1612
+ return;
1613
+ }
1614
+ // For POST, be more flexible - allow if client accepts either JSON or SSE
1615
+ if (req.method === 'POST' && accept && !acceptsJson && !acceptsEventStream) {
1616
+ this.logger.debug('Accept header validation failed for POST, returning 406');
1617
+ res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
1618
+ error: 'Not Acceptable',
1619
+ message: `POST requests must accept ${MIME_TYPES.JSON} or ${MIME_TYPES.EVENT_STREAM}`
1620
+ });
1621
+ return;
1622
+ }
1623
+ // Check if this is initialization (no session ID yet)
1624
+ const isInitialization = isInitializeRequest(body);
1625
+ this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
1626
+ // Validate session (except for initialization)
1627
+ if (!isInitialization && sessionId) {
1628
+ const session = profileState.sessions.get(sessionId);
1629
+ if (!session) {
1630
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
1631
+ return;
1632
+ }
1633
+ if (filteringHeader !== undefined) {
1634
+ if (!session.filteringHeader || session.filteringHeader !== filteringHeader) {
1635
+ throw new ValidationError('X-Mcp4-Params header mismatch for existing session.');
1636
+ }
1637
+ }
1638
+ if (normalizedToolFilterHeader !== undefined) {
1639
+ if (!session.toolFilterHeader || session.toolFilterHeader !== normalizedToolFilterHeader) {
1640
+ throw new ValidationError(`X-Mcp4-Tools header mismatch for existing session. Expected: '${session.toolFilterHeader ?? ''}', Got: '${normalizedToolFilterHeader}'.`);
1641
+ }
1642
+ }
1643
+ this.updateSessionActivity(profileState, sessionId);
1644
+ const authInfo = this.extractAuthToken(req, profileState);
1645
+ if (authInfo.token && authInfo.type !== 'oauth') {
1646
+ const previousToken = session.authToken;
1647
+ if (!previousToken || previousToken !== authInfo.token) {
1648
+ session.authToken = authInfo.token;
1649
+ this.logger.info('Session auth token updated', {
1650
+ profileId: profileState.profileId,
1651
+ sessionId,
1652
+ authType: authInfo.type,
1653
+ replaced: !!previousToken,
1654
+ });
1655
+ }
1656
+ }
1657
+ }
1658
+ else if (!isInitialization && !sessionId) {
1659
+ this.logger.debug('Session validation failed: non-init request without sessionId');
1660
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
1661
+ return;
1662
+ }
1663
+ // Determine message type
1664
+ const messageType = this.getMessageType(body);
1665
+ this.logger.debug('Message type determined', { messageType, hasMessageHandler: !!this.messageHandler });
1666
+ // If only notifications/responses, return 202 Accepted
1667
+ if (messageType === 'notification-only' || messageType === 'response-only') {
1668
+ if (this.messageHandler) {
1669
+ await this.messageHandler(body, undefined, requestProfileId);
1670
+ }
1671
+ res.status(HTTP_STATUS.ACCEPTED).send();
1672
+ return;
1673
+ }
1674
+ // If contains requests, process and return response
1675
+ if (messageType === 'request') {
1676
+ if (!this.messageHandler) {
1677
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
1678
+ return;
1679
+ }
1680
+ // Create session on initialization
1681
+ let newSessionId;
1682
+ if (isInitialization) {
1683
+ // Extract and validate auth token from headers
1684
+ const authInfo = this.extractAuthToken(req, profileState);
1685
+ this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
1686
+ // If OAuth is configured, require authentication for initialization
1687
+ // This ensures clients like Cursor properly handle OAuth flow
1688
+ if (profileState.oauthProvider && !authInfo.token) {
1689
+ this.logger.debug('OAuth configured but no token provided, triggering OAuth flow');
1690
+ const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
1691
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${profileState.oauthProvider.scopes.join(' ')}"`);
1692
+ res.status(HTTP_STATUS.UNAUTHORIZED).json({
1693
+ error: 'Unauthorized',
1694
+ message: 'Authentication required for OAuth'
1695
+ });
1696
+ return;
1697
+ }
1698
+ // Allow initialization without token for non-OAuth scenarios
1699
+ // Validate token if auth is configured and token is provided
1700
+ if (authInfo && authInfo.token && profileState.context.authConfigs && profileState.context.baseUrl) {
1701
+ // Find matching auth config based on priority (authConfigs is sorted)
1702
+ // For 'bearer' token type, 'oauth' config is also a match
1703
+ const authConfig = profileState.context.authConfigs.find(c => c.type === authInfo.type ||
1704
+ (authInfo.type === 'bearer' && c.type === 'oauth'));
1705
+ if (authConfig && authConfig.validation_endpoint) {
1706
+ this.logger.info('Validating auth token during initialization', {
1707
+ authType: authConfig.type, // Use config type for logging
1708
+ endpoint: authConfig.validation_endpoint,
1709
+ });
1710
+ const isValid = await this.validateAuthToken(authConfig, authInfo.token, profileState.context.baseUrl);
1711
+ if (!isValid) {
1712
+ this.logger.warn('Auth token validation failed during initialization', {
1713
+ authType: authInfo.type,
1714
+ });
1715
+ res.status(HTTP_STATUS.UNAUTHORIZED).json({
1716
+ error: 'Unauthorized',
1717
+ message: 'Invalid or expired authentication token'
1718
+ });
1719
+ return;
1720
+ }
1721
+ this.logger.info('Auth token validation successful');
1722
+ }
1723
+ }
1724
+ // Look up OAuth tokens if this is an OAuth token
1725
+ let refreshToken;
1726
+ let accessTokenExpiresAt;
1727
+ let scopes;
1728
+ let oauthClientId;
1729
+ if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
1730
+ const tokenData = profileState.oauthTokensByAccessToken.get(authInfo.token);
1731
+ if (tokenData) {
1732
+ refreshToken = tokenData.refreshToken;
1733
+ accessTokenExpiresAt = tokenData.expiresAt;
1734
+ scopes = tokenData.scopes;
1735
+ oauthClientId = tokenData.clientId;
1736
+ this.logger.debug('Found OAuth token data for session', {
1737
+ hasRefreshToken: !!refreshToken,
1738
+ hasExpiration: !!accessTokenExpiresAt,
1739
+ scopesCount: scopes.length,
1740
+ });
1741
+ }
1742
+ else {
1743
+ this.logger.debug('No OAuth token data found in map (may be non-OAuth bearer token)', {
1744
+ hasToken: true,
1745
+ });
1746
+ }
1747
+ }
1748
+ newSessionId = this.createSession(profileState, authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, parsedFiltering?.filtering, parsedFiltering?.normalizedHeader, parsedToolFilter, normalizedToolFilterHeader);
1749
+ }
1750
+ this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
1751
+ const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId, requestProfileId);
1752
+ this.logger.debug('MessageHandler response', { response });
1753
+ // Debug: Check OAuth conditions
1754
+ this.logger.debug('Checking OAuth conditions', {
1755
+ responseError: response.error,
1756
+ hasOAuthProvider: !!profileState.oauthProvider,
1757
+ oauthProviderType: typeof profileState.oauthProvider
1758
+ });
1759
+ // Check if response contains OAuth error and add WWW-Authenticate header
1760
+ const responseObj = response;
1761
+ if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
1762
+ const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
1763
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
1764
+ res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
1765
+ }
1766
+ // Decide response format based on Accept header
1767
+ const accept = req.headers.accept || '';
1768
+ const wantsOnlySSE = accept.trim() === MIME_TYPES.EVENT_STREAM;
1769
+ if (wantsOnlySSE) {
1770
+ // Return SSE response only when client explicitly wants text/event-stream only
1771
+ this.logger.debug('Sending SSE response', { response, newSessionId });
1772
+ this.startSSEResponse(res, response, newSessionId, sessionId);
1773
+ }
1774
+ else {
1775
+ // Return JSON response (default for requests)
1776
+ if (newSessionId) {
1777
+ res.setHeader('Mcp-Session-Id', newSessionId);
1778
+ }
1779
+ // Security: Prevent caching of sensitive API responses
1780
+ res.setHeader('Cache-Control', 'no-store');
1781
+ this.logger.debug('Sending JSON response', { response, newSessionId });
1782
+ res.json(response);
1783
+ }
1784
+ return;
1785
+ }
1786
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Invalid message type' });
1787
+ }
1788
+ catch (error) {
1789
+ const correlationId = generateCorrelationId();
1790
+ this.logger.error('POST request error', error, { correlationId });
1791
+ res.setHeader('Cache-Control', 'no-store');
1792
+ let status = 500;
1793
+ let errorLabel = 'Internal Server Error';
1794
+ let message = `Internal error (correlation ID: ${correlationId})`;
1795
+ if (error instanceof ValidationError) {
1796
+ status = HTTP_STATUS.BAD_REQUEST;
1797
+ errorLabel = 'Bad Request';
1798
+ message = `Validation error: ${error.message} (correlation ID: ${correlationId})`;
1799
+ }
1800
+ else if (error instanceof AuthenticationError) {
1801
+ status = HTTP_STATUS.UNAUTHORIZED;
1802
+ errorLabel = 'Unauthorized';
1803
+ message = `Authentication failed: ${error.message} (correlation ID: ${correlationId})`;
1804
+ }
1805
+ else if (error instanceof AuthorizationError) {
1806
+ status = HTTP_STATUS.FORBIDDEN;
1807
+ errorLabel = 'Forbidden';
1808
+ message = `Authorization failed: ${error.message} (correlation ID: ${correlationId})`;
1809
+ }
1810
+ else if (error instanceof RateLimitError) {
1811
+ status = HTTP_STATUS.TOO_MANY_REQUESTS;
1812
+ errorLabel = 'Too Many Requests';
1813
+ message = `Rate limit exceeded: ${error.message} (correlation ID: ${correlationId})`;
1814
+ }
1815
+ res.status(status).json({ error: errorLabel, message, correlationId });
1816
+ // Record error metrics
1817
+ if (this.metrics) {
1818
+ const duration = (Date.now() - startTime) / 1000;
1819
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1820
+ }
1821
+ }
1822
+ finally {
1823
+ // Record success metrics (if not already recorded in catch)
1824
+ if (this.metrics && res.statusCode !== 500) {
1825
+ const duration = (Date.now() - startTime) / 1000;
1826
+ this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
1827
+ }
1828
+ }
1829
+ }
1830
+ /**
1831
+ * Handle GET requests - Client opening SSE stream for server messages
1832
+ *
1833
+ * MCP Spec: GET opens SSE stream for server-initiated requests/notifications
1834
+ */
1835
+ async handleGet(req, res) {
1836
+ const startTime = Date.now();
1837
+ try {
1838
+ const profileState = await this.getProfileStateForRequest(req);
1839
+ if (!profileState) {
1840
+ this.respondProfileNotFound(res, req.profileId);
1841
+ return;
1842
+ }
1843
+ const sessionId = req.sessionId;
1844
+ const lastEventId = req.headers['last-event-id'];
1845
+ // Validate Accept header
1846
+ const accept = req.headers.accept || '';
1847
+ if (!accept.includes(MIME_TYPES.EVENT_STREAM)) {
1848
+ res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: 'Method Not Allowed', message: `Must accept ${MIME_TYPES.EVENT_STREAM}` });
1849
+ return;
1850
+ }
1851
+ // Validate session
1852
+ if (!sessionId) {
1853
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
1854
+ return;
1855
+ }
1856
+ const session = profileState.sessions.get(sessionId);
1857
+ if (!session) {
1858
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
1859
+ return;
1860
+ }
1861
+ this.updateSessionActivity(profileState, sessionId);
1862
+ // Start SSE stream
1863
+ this.startSSEStream(res, sessionId, lastEventId, profileState);
1864
+ // Record metrics for successful SSE start
1865
+ if (this.metrics) {
1866
+ const duration = (Date.now() - startTime) / 1000;
1867
+ this.metrics.recordHttpRequest(req.method, req.path, 200, duration);
1868
+ }
1869
+ }
1870
+ catch (error) {
1871
+ const correlationId = generateCorrelationId();
1872
+ this.logger.error('GET request error', error, { correlationId });
1873
+ const status = 500;
1874
+ if (!res.headersSent) {
1875
+ res.setHeader('Cache-Control', 'no-store');
1876
+ res.status(status).json({
1877
+ error: 'Internal Server Error',
1878
+ message: `Internal error (correlation ID: ${correlationId})`,
1879
+ correlationId
1880
+ });
1881
+ }
1882
+ // Record error metrics
1883
+ if (this.metrics) {
1884
+ const duration = (Date.now() - startTime) / 1000;
1885
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1886
+ }
1887
+ }
1888
+ }
1889
+ /**
1890
+ * Handle DELETE requests - Client terminating session
1891
+ *
1892
+ * MCP Spec: DELETE explicitly terminates session
1893
+ */
1894
+ handleDelete(req, res) {
1895
+ const startTime = Date.now();
1896
+ const sessionId = req.sessionId;
1897
+ const profileId = this.getProfileIdForRequest(req);
1898
+ if (!profileId) {
1899
+ this.respondProfileNotFound(res, req.profileId);
1900
+ return;
1901
+ }
1902
+ if (!sessionId) {
1903
+ const status = 400;
1904
+ res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
1905
+ if (this.metrics) {
1906
+ const duration = (Date.now() - startTime) / 1000;
1907
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1908
+ }
1909
+ return;
1910
+ }
1911
+ const profileState = this.profileStates.get(profileId);
1912
+ if (!profileState) {
1913
+ this.respondProfileNotFound(res, profileId);
1914
+ return;
1915
+ }
1916
+ const session = profileState.sessions.get(sessionId);
1917
+ if (!session) {
1918
+ const status = 404;
1919
+ res.status(status).json({ error: 'Not Found', message: 'Session not found' });
1920
+ if (this.metrics) {
1921
+ const duration = (Date.now() - startTime) / 1000;
1922
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1923
+ }
1924
+ return;
1925
+ }
1926
+ this.destroySession(profileState, sessionId);
1927
+ const status = 204;
1928
+ res.status(status).send();
1929
+ if (this.metrics) {
1930
+ const duration = (Date.now() - startTime) / 1000;
1931
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1932
+ }
1933
+ }
1934
+ /**
1935
+ * Start SSE response for a POST request
1936
+ *
1937
+ * Why: Returns response via SSE stream, allows server-initiated messages
1938
+ */
1939
+ startSSEResponse(res, response, newSessionId, sessionId) {
1940
+ res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
1941
+ res.setHeader('Cache-Control', 'no-cache');
1942
+ res.setHeader('Connection', 'keep-alive');
1943
+ if (newSessionId) {
1944
+ res.setHeader('Mcp-Session-Id', newSessionId);
1945
+ }
1946
+ // Send response
1947
+ const eventId = Date.now();
1948
+ res.write(`id: ${eventId}\n`);
1949
+ res.write(`data: ${JSON.stringify(response)}\n\n`);
1950
+ // Close stream
1951
+ res.end();
1952
+ }
1953
+ /**
1954
+ * Start SSE stream for GET request
1955
+ *
1956
+ * Why: Allows server to send requests/notifications to client
1957
+ */
1958
+ startSSEStream(res, sessionId, lastEventId, profileState) {
1959
+ res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
1960
+ res.setHeader('Cache-Control', 'no-cache');
1961
+ res.setHeader('Connection', 'keep-alive');
1962
+ const streamId = crypto.randomBytes(16).toString('hex');
1963
+ const session = profileState.sessions.get(sessionId);
1964
+ const streamState = {
1965
+ streamId,
1966
+ lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
1967
+ messageQueue: [],
1968
+ active: true,
1969
+ response: res,
1970
+ };
1971
+ session.sseStreams.set(streamId, streamState);
1972
+ // Replay missed messages if resuming
1973
+ if (lastEventId) {
1974
+ this.replayMessages(res, streamState);
1975
+ }
1976
+ // Setup heartbeat if enabled
1977
+ let heartbeatInterval = null;
1978
+ if (this.config.heartbeatEnabled) {
1979
+ heartbeatInterval = setInterval(() => {
1980
+ if (streamState.active) {
1981
+ res.write(':ping\n\n');
1982
+ }
1983
+ }, this.config.heartbeatIntervalMs);
1984
+ }
1985
+ // Handle client disconnect
1986
+ res.on('close', () => {
1987
+ streamState.active = false;
1988
+ if (heartbeatInterval) {
1989
+ clearInterval(heartbeatInterval);
1990
+ }
1991
+ this.logger.info('SSE stream closed', { sessionId, streamId });
1992
+ });
1993
+ this.logger.info('SSE stream opened', { sessionId, streamId, resuming: !!lastEventId });
1994
+ }
1995
+ /**
1996
+ * Replay messages after Last-Event-ID
1997
+ *
1998
+ * Why: Resumability - client can reconnect and receive missed messages
1999
+ */
2000
+ replayMessages(res, streamState) {
2001
+ const missedMessages = streamState.messageQueue.filter(msg => msg.eventId > streamState.lastEventId);
2002
+ for (const msg of missedMessages) {
2003
+ res.write(`id: ${msg.eventId}\n`);
2004
+ res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
2005
+ }
2006
+ this.logger.info('Replayed messages', { count: missedMessages.length, streamId: streamState.streamId });
2007
+ }
2008
+ /**
2009
+ * Send message to client via SSE
2010
+ *
2011
+ * Why: Server-initiated requests/notifications
2012
+ */
2013
+ sendToClient(profileId, sessionId, message) {
2014
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2015
+ if (!session) {
2016
+ this.logger.warn('Cannot send to client: session not found', { profileId, sessionId });
2017
+ return;
2018
+ }
2019
+ const eventId = Date.now();
2020
+ const queuedMessage = {
2021
+ eventId,
2022
+ data: message,
2023
+ timestamp: Date.now(),
2024
+ };
2025
+ // Send to all active streams for this session
2026
+ for (const [streamId, streamState] of session.sseStreams) {
2027
+ if (streamState.active) {
2028
+ // Queue for resumability
2029
+ streamState.messageQueue.push(queuedMessage);
2030
+ // Keep only last 100 messages
2031
+ if (streamState.messageQueue.length > 100) {
2032
+ streamState.messageQueue.shift();
2033
+ }
2034
+ }
2035
+ }
2036
+ }
2037
+ /**
2038
+ * Determine message type (request, notification, response)
2039
+ */
2040
+ getMessageType(body) {
2041
+ if (Array.isArray(body)) {
2042
+ // Batch
2043
+ const hasRequest = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && 'id' in msg);
2044
+ const hasNotification = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && !('id' in msg));
2045
+ const hasResponse = body.some((msg) => typeof msg === 'object' && msg !== null && ('result' in msg || 'error' in msg));
2046
+ if (hasRequest)
2047
+ return 'request';
2048
+ if (hasNotification && !hasResponse)
2049
+ return 'notification-only';
2050
+ if (hasResponse && !hasNotification)
2051
+ return 'response-only';
2052
+ return 'mixed';
2053
+ }
2054
+ else if (typeof body === 'object' && body !== null) {
2055
+ const msg = body;
2056
+ if ('method' in msg) {
2057
+ return 'id' in msg ? 'request' : 'notification-only';
2058
+ }
2059
+ if ('result' in msg || 'error' in msg) {
2060
+ return 'response-only';
2061
+ }
2062
+ }
2063
+ return 'unknown';
2064
+ }
2065
+ getFilteringHeaderValue(req) {
2066
+ const headerValue = req.headers['x-mcp4-params'];
2067
+ if (Array.isArray(headerValue)) {
2068
+ if (headerValue.length === 0) {
2069
+ return undefined;
2070
+ }
2071
+ if (headerValue.length > 1) {
2072
+ throw new ValidationError('Invalid X-Mcp4-Params header. Expected comma-separated key=value pairs.');
2073
+ }
2074
+ return headerValue[0];
2075
+ }
2076
+ return headerValue;
2077
+ }
2078
+ getToolFilterHeaderValue(req) {
2079
+ const headerValue = req.headers['x-mcp4-tools'];
2080
+ if (Array.isArray(headerValue)) {
2081
+ if (headerValue.length === 0) {
2082
+ return undefined;
2083
+ }
2084
+ if (headerValue.length > 1) {
2085
+ throw new ValidationError('Invalid X-Mcp4-Tools header. Expected comma-separated tool names.');
2086
+ }
2087
+ return headerValue[0];
2088
+ }
2089
+ return headerValue;
2090
+ }
2091
+ /**
2092
+ * Create new session
2093
+ *
2094
+ * Why: Stateful sessions for MCP protocol
2095
+ */
2096
+ createSession(profileState, authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, filtering, filteringHeader, toolFilterRequest, toolFilterHeader) {
2097
+ // Validate token if provided (defense in depth)
2098
+ if (authToken) {
2099
+ this.validateToken(authToken, 'Session auth token');
2100
+ }
2101
+ const sessionId = crypto.randomUUID();
2102
+ const session = {
2103
+ id: sessionId,
2104
+ createdAt: Date.now(),
2105
+ lastActivityAt: Date.now(),
2106
+ sseStreams: new Map(),
2107
+ authToken,
2108
+ refreshToken,
2109
+ accessTokenExpiresAt,
2110
+ scopes,
2111
+ oauthClientId,
2112
+ filtering,
2113
+ filteringHeader,
2114
+ toolFilterRequest,
2115
+ toolFilterHeader,
2116
+ };
2117
+ profileState.sessions.set(sessionId, session);
2118
+ this.logger.info('Session created', {
2119
+ profileId: profileState.profileId,
2120
+ sessionId,
2121
+ hasAuthToken: !!authToken,
2122
+ hasRefreshToken: !!refreshToken,
2123
+ hasExpiration: !!accessTokenExpiresAt,
2124
+ });
2125
+ // Record metrics
2126
+ if (this.metrics) {
2127
+ this.metrics.recordSessionCreated();
2128
+ }
2129
+ return sessionId;
2130
+ }
2131
+ /**
2132
+ * Update session activity timestamp
2133
+ */
2134
+ updateSessionActivity(profileState, sessionId) {
2135
+ const session = profileState.sessions.get(sessionId);
2136
+ if (session) {
2137
+ session.lastActivityAt = Date.now();
2138
+ }
2139
+ }
2140
+ /**
2141
+ * Destroy session and cleanup resources
2142
+ *
2143
+ * Why: Free memory, close streams
2144
+ */
2145
+ destroySession(profileState, sessionId) {
2146
+ const session = profileState.sessions.get(sessionId);
2147
+ if (session) {
2148
+ // Close all active SSE streams
2149
+ for (const [, streamState] of session.sseStreams) {
2150
+ streamState.active = false;
2151
+ // Close the HTTP response to terminate the SSE connection
2152
+ try {
2153
+ if (!streamState.response.headersSent || !streamState.response.writableEnded) {
2154
+ streamState.response.end();
2155
+ }
2156
+ }
2157
+ catch (error) {
2158
+ // Ignore errors if response is already closed
2159
+ this.logger.debug('Failed to close SSE response', { error: error.message });
2160
+ }
2161
+ }
2162
+ session.sseStreams.clear();
2163
+ // Clean up OAuth token from map if present
2164
+ if (session.authToken) {
2165
+ profileState.oauthTokensByAccessToken.delete(session.authToken);
2166
+ }
2167
+ profileState.sessions.delete(sessionId);
2168
+ this.logger.info('Session destroyed', { profileId: profileState.profileId, sessionId });
2169
+ // Notify session destruction listeners (for cleanup in MCPServer)
2170
+ this.notifySessionDestroyed(profileState.profileId, sessionId);
2171
+ // Record metrics
2172
+ if (this.metrics) {
2173
+ this.metrics.recordSessionDestroyed();
2174
+ this.metrics.clearToolsSession(sessionId);
2175
+ }
2176
+ }
2177
+ }
2178
+ /**
2179
+ * Register listener for session destruction events
2180
+ *
2181
+ * Why: Allows MCPServer to cleanup per-session HTTP clients
2182
+ */
2183
+ onSessionDestroyed(listener) {
2184
+ this.sessionDestroyedListeners.push(listener);
2185
+ }
2186
+ /**
2187
+ * Notify all listeners about session destruction
2188
+ */
2189
+ notifySessionDestroyed(profileId, sessionId) {
2190
+ for (const listener of this.sessionDestroyedListeners) {
2191
+ try {
2192
+ listener(profileId, sessionId);
2193
+ }
2194
+ catch (error) {
2195
+ this.logger.error('Session destroyed listener error', error);
2196
+ }
2197
+ }
2198
+ }
2199
+ /**
2200
+ * Store OAuth tokens in internal map for later session initialization
2201
+ *
2202
+ * Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
2203
+ * and session initialization (where we only see access token in Authorization header)
2204
+ */
2205
+ storeOAuthTokens(profileState, tokens, clientId, scopes) {
2206
+ if (!tokens.access_token) {
2207
+ this.logger.warn('OAuth tokens missing access_token, skipping storage');
2208
+ return;
2209
+ }
2210
+ const expiresAt = tokens.expires_in
2211
+ ? Date.now() + tokens.expires_in * 1000
2212
+ : undefined;
2213
+ profileState.oauthTokensByAccessToken.set(tokens.access_token, {
2214
+ refreshToken: tokens.refresh_token,
2215
+ expiresAt,
2216
+ clientId,
2217
+ scopes,
2218
+ });
2219
+ this.logger.debug('Stored OAuth tokens', {
2220
+ profileId: profileState.profileId,
2221
+ hasRefreshToken: !!tokens.refresh_token,
2222
+ expiresAt,
2223
+ clientId,
2224
+ scopesCount: scopes.length,
2225
+ });
2226
+ }
2227
+ /**
2228
+ * Cleanup expired sessions
2229
+ *
2230
+ * Why: Prevent memory leaks, enforce session timeout
2231
+ *
2232
+ * OAuth sessions with refresh tokens have extended or unlimited timeout
2233
+ * to avoid forcing users to re-authenticate after periods of inactivity
2234
+ */
2235
+ cleanupExpiredSessions() {
2236
+ const now = Date.now();
2237
+ const expiredSessions = [];
2238
+ // Default OAuth session timeout: 24 hours (or configurable)
2239
+ const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
2240
+ ?? (24 * 60 * 60 * 1000); // 24 hours default
2241
+ for (const profileState of this.profileStates.values()) {
2242
+ // Cleanup OAuth provider resources (states, codes, tokens)
2243
+ if (profileState.oauthProvider) {
2244
+ profileState.oauthProvider.cleanup();
2245
+ }
2246
+ for (const [sessionId, session] of profileState.sessions) {
2247
+ const age = now - session.lastActivityAt;
2248
+ // OAuth sessions with refresh tokens: use extended timeout or never expire
2249
+ if (session.refreshToken) {
2250
+ // If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
2251
+ if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
2252
+ expiredSessions.push({ profileId: profileState.profileId, sessionId });
2253
+ }
2254
+ }
2255
+ else {
2256
+ // Non-OAuth sessions: use standard timeout
2257
+ if (age > this.config.sessionTimeoutMs) {
2258
+ expiredSessions.push({ profileId: profileState.profileId, sessionId });
2259
+ }
2260
+ }
2261
+ }
2262
+ }
2263
+ for (const entry of expiredSessions) {
2264
+ const state = this.profileStates.get(entry.profileId);
2265
+ if (state) {
2266
+ this.destroySession(state, entry.sessionId);
2267
+ }
2268
+ }
2269
+ if (expiredSessions.length > 0) {
2270
+ this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
2271
+ }
2272
+ }
2273
+ /**
2274
+ * Get auth token from session
2275
+ *
2276
+ * Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
2277
+ */
2278
+ getSessionToken(profileId, sessionId) {
2279
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2280
+ return session?.authToken;
2281
+ }
2282
+ getSessionFiltering(profileId, sessionId) {
2283
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2284
+ return session?.filtering;
2285
+ }
2286
+ getSessionFilteringHeader(profileId, sessionId) {
2287
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2288
+ return session?.filteringHeader;
2289
+ }
2290
+ getSessionToolFilterRequest(profileId, sessionId) {
2291
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2292
+ return session?.toolFilterRequest;
2293
+ }
2294
+ getSessionToolFilter(profileId, sessionId) {
2295
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2296
+ return session?.toolFilter;
2297
+ }
2298
+ getSessionToolFilterHeader(profileId, sessionId) {
2299
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2300
+ return session?.toolFilterHeader;
2301
+ }
2302
+ setSessionToolFilter(profileId, sessionId, toolFilter) {
2303
+ const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2304
+ if (!session) {
2305
+ return;
2306
+ }
2307
+ session.toolFilter = toolFilter;
2308
+ }
2309
+ recordGlobalToolFilterMetrics(summary) {
2310
+ if (!this.metrics) {
2311
+ return;
2312
+ }
2313
+ this.metrics.recordToolsTotal('profile', summary.originalCount);
2314
+ this.metrics.recordToolsFiltered('global_env', 'allowed', summary.allowedCount);
2315
+ this.metrics.recordToolsFiltered('global_env', 'denied', summary.removedCount);
2316
+ for (const [type, count] of Object.entries(summary.patternCounts)) {
2317
+ this.metrics.recordToolFilterPatternCount(type, count);
2318
+ }
2319
+ }
2320
+ recordSessionToolFilterMetrics(sessionId, allowedCount, request) {
2321
+ if (!this.metrics) {
2322
+ return;
2323
+ }
2324
+ this.metrics.recordToolsSession(sessionId, allowedCount);
2325
+ this.metrics.recordToolFilterPatternCount('session_allow_list', request.exactNames.size);
2326
+ this.metrics.recordToolFilterPatternCount('session_allow_regex', request.regexPatterns.length);
2327
+ }
2328
+ recordToolFilterRejection(tool, source) {
2329
+ if (!this.metrics) {
2330
+ return;
2331
+ }
2332
+ this.metrics.recordToolFilterRejection(tool, source);
2333
+ }
2334
+ /**
2335
+ * Ensure session has a valid access token, refreshing if necessary
2336
+ *
2337
+ * Why: Transparently refresh expired OAuth tokens before making API calls
2338
+ * Returns true if token is valid (or was successfully refreshed), false otherwise
2339
+ */
2340
+ async ensureValidSessionToken(profileId, sessionId) {
2341
+ const profileState = this.profileStates.get(profileId);
2342
+ const session = profileState?.sessions.get(sessionId);
2343
+ if (!session) {
2344
+ return false;
2345
+ }
2346
+ // If no expiration info, assume token is valid (non-OAuth scenarios)
2347
+ if (!session.accessTokenExpiresAt) {
2348
+ return true;
2349
+ }
2350
+ const now = Date.now();
2351
+ const refreshThresholdMs = this.config.oauthRefreshThresholdMs ?? (60 * 1000); // Default: 60 seconds before expiration
2352
+ const timeUntilExpiration = session.accessTokenExpiresAt - now;
2353
+ // If token is expired or about to expire, refresh it
2354
+ if (timeUntilExpiration <= refreshThresholdMs) {
2355
+ this.logger.debug('Access token expired or expiring soon, refreshing', {
2356
+ profileId,
2357
+ sessionId,
2358
+ expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
2359
+ timeUntilExpiration,
2360
+ });
2361
+ return await this.refreshAccessToken(profileId, sessionId);
2362
+ }
2363
+ return true;
2364
+ }
2365
+ /**
2366
+ * Refresh access token using refresh token
2367
+ *
2368
+ * Why: Automatically renew expired OAuth access tokens without user intervention
2369
+ * Returns true on success, false on failure
2370
+ */
2371
+ async refreshAccessToken(profileId, sessionId) {
2372
+ const profileState = this.profileStates.get(profileId);
2373
+ const session = profileState?.sessions.get(sessionId);
2374
+ if (!profileState || !session || !session.refreshToken || !profileState.oauthProvider) {
2375
+ this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
2376
+ profileId,
2377
+ sessionId,
2378
+ hasSession: !!session,
2379
+ hasRefreshToken: !!session?.refreshToken,
2380
+ hasOAuthProvider: !!profileState?.oauthProvider,
2381
+ });
2382
+ return false;
2383
+ }
2384
+ try {
2385
+ // Get client from OAuth provider
2386
+ // Try to find client by clientId stored in session, or use default client
2387
+ let client;
2388
+ if (session.oauthClientId) {
2389
+ await profileState.oauthProvider.ensureEndpointsInitialized();
2390
+ client = await profileState.oauthProvider.clientsStore.getClient(session.oauthClientId);
2391
+ }
2392
+ // Fallback to default client from config if session client not found
2393
+ if (!client && profileState.oauthProvider) {
2394
+ await profileState.oauthProvider.ensureEndpointsInitialized();
2395
+ // Try common client IDs
2396
+ const defaultClientIds = ['mcp-proxy-client'];
2397
+ if (profileState.context.oauthConfig?.client_id) {
2398
+ defaultClientIds.unshift(profileState.context.oauthConfig.client_id);
2399
+ }
2400
+ for (const clientId of defaultClientIds) {
2401
+ client = await profileState.oauthProvider.clientsStore.getClient(clientId);
2402
+ if (client)
2403
+ break;
2404
+ }
2405
+ }
2406
+ if (!client) {
2407
+ this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
2408
+ profileId,
2409
+ sessionId,
2410
+ oauthClientId: session.oauthClientId,
2411
+ });
2412
+ return false;
2413
+ }
2414
+ // Exchange refresh token for new tokens
2415
+ const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
2416
+ // Update session with new tokens
2417
+ const oldAccessToken = session.authToken;
2418
+ session.authToken = tokens.access_token;
2419
+ session.refreshToken = tokens.refresh_token || session.refreshToken; // Keep old refresh token if new one not provided
2420
+ session.accessTokenExpiresAt = tokens.expires_in
2421
+ ? Date.now() + tokens.expires_in * 1000
2422
+ : undefined;
2423
+ // Update token map: remove old token, add new one
2424
+ if (oldAccessToken) {
2425
+ profileState.oauthTokensByAccessToken.delete(oldAccessToken);
2426
+ }
2427
+ this.storeOAuthTokens(profileState, tokens, client.client_id, session.scopes || []);
2428
+ this.logger.info('Access token refreshed successfully', {
2429
+ profileId,
2430
+ sessionId,
2431
+ newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
2432
+ });
2433
+ return true;
2434
+ }
2435
+ catch (error) {
2436
+ this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
2437
+ profileId,
2438
+ sessionId,
2439
+ });
2440
+ return false;
2441
+ }
2442
+ }
2443
+ /**
2444
+ * Set message handler for processing incoming JSON-RPC messages
2445
+ */
2446
+ setMessageHandler(handler) {
2447
+ this.messageHandler = handler;
2448
+ }
2449
+ /**
2450
+ * Check if OAuth provider is configured
2451
+ */
2452
+ hasOAuthProvider(profileId) {
2453
+ if (!profileId) {
2454
+ const defaultProfileId = this.getDefaultProfileId();
2455
+ if (!defaultProfileId) {
2456
+ return false;
2457
+ }
2458
+ const state = this.profileStates.get(defaultProfileId);
2459
+ return state ? state.oauthProvider !== null : false;
2460
+ }
2461
+ const state = this.profileStates.get(profileId);
2462
+ return state ? state.oauthProvider !== null : false;
2463
+ }
2464
+ /**
2465
+ * Get server URL
2466
+ */
2467
+ getServerUrl(profileId) {
2468
+ return this.getServerOrigin(profileId);
2469
+ }
2470
+ /**
2471
+ * Get OAuth authorization URL
2472
+ */
2473
+ getOAuthAuthorizationUrl(profileId) {
2474
+ if (!profileId) {
2475
+ const defaultProfileId = this.getDefaultProfileId();
2476
+ if (!defaultProfileId) {
2477
+ return '';
2478
+ }
2479
+ return this.profileStates.get(defaultProfileId)?.oauthProvider?.authorizationEndpoint || '';
2480
+ }
2481
+ return this.profileStates.get(profileId)?.oauthProvider?.authorizationEndpoint || '';
2482
+ }
2483
+ /**
2484
+ * Get OAuth scopes
2485
+ */
2486
+ getOAuthScopes(profileId) {
2487
+ if (!profileId) {
2488
+ const defaultProfileId = this.getDefaultProfileId();
2489
+ if (!defaultProfileId) {
2490
+ return [];
2491
+ }
2492
+ return this.profileStates.get(defaultProfileId)?.oauthProvider?.scopes || [];
2493
+ }
2494
+ return this.profileStates.get(profileId)?.oauthProvider?.scopes || [];
2495
+ }
2496
+ /**
2497
+ * Start HTTP server
2498
+ */
2499
+ async start() {
2500
+ return new Promise((resolve, reject) => {
2501
+ try {
2502
+ // Check for SSL configuration from environment variables
2503
+ const sslCertFile = process.env.MCP4_SSL_CERT_FILE;
2504
+ const sslKeyFile = process.env.MCP4_SSL_KEY_FILE;
2505
+ if (sslCertFile && sslKeyFile) {
2506
+ // Start HTTPS server
2507
+ this.logger.info('SSL configuration detected, starting HTTPS server', {
2508
+ certFile: sslCertFile,
2509
+ keyFile: sslKeyFile,
2510
+ });
2511
+ try {
2512
+ const httpsOptions = {
2513
+ cert: fs.readFileSync(sslCertFile),
2514
+ key: fs.readFileSync(sslKeyFile),
2515
+ };
2516
+ this.server = https.createServer(httpsOptions, this.app);
2517
+ this.server.listen(this.config.port, this.config.host, () => {
2518
+ this.logger.info('HTTPS transport started', {
2519
+ host: this.config.host,
2520
+ port: this.config.port,
2521
+ heartbeat: this.config.heartbeatEnabled,
2522
+ metrics: this.config.metricsEnabled,
2523
+ });
2524
+ // Start session cleanup interval
2525
+ this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
2526
+ resolve();
2527
+ });
2528
+ }
2529
+ catch (sslError) {
2530
+ this.logger.error('Failed to start HTTPS server', sslError instanceof Error ? sslError : new Error(String(sslError)));
2531
+ reject(sslError);
2532
+ return;
2533
+ }
2534
+ }
2535
+ else {
2536
+ // Start HTTP server
2537
+ this.server = this.app.listen(this.config.port, this.config.host, () => {
2538
+ this.logger.info('HTTP transport started', {
2539
+ host: this.config.host,
2540
+ port: this.config.port,
2541
+ heartbeat: this.config.heartbeatEnabled,
2542
+ metrics: this.config.metricsEnabled,
2543
+ });
2544
+ // Start session cleanup interval
2545
+ this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
2546
+ resolve();
2547
+ });
2548
+ }
2549
+ this.server.on('error', reject);
2550
+ }
2551
+ catch (error) {
2552
+ reject(error);
2553
+ }
2554
+ });
2555
+ }
2556
+ /**
2557
+ * Stop HTTP server
2558
+ */
2559
+ async stop() {
2560
+ if (this.cleanupInterval) {
2561
+ clearInterval(this.cleanupInterval);
2562
+ this.cleanupInterval = null;
2563
+ }
2564
+ // Destroy all sessions
2565
+ for (const profileState of this.profileStates.values()) {
2566
+ for (const sessionId of profileState.sessions.keys()) {
2567
+ this.destroySession(profileState, sessionId);
2568
+ }
2569
+ }
2570
+ if (this.server) {
2571
+ return new Promise((resolve, reject) => {
2572
+ this.server.close((err) => {
2573
+ if (err)
2574
+ reject(err);
2575
+ else {
2576
+ this.logger.info('HTTP transport stopped');
2577
+ resolve();
2578
+ }
2579
+ });
2580
+ });
2581
+ }
2582
+ }
2583
+ }
2584
+ //# sourceMappingURL=http-transport.js.map