mcp4openapi 0.2.7 → 0.3.0

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