@animalabs/agent-framework 0.32.0 → 0.34.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 (368) hide show
  1. package/README.md +340 -22
  2. package/dist/src/admission/admit.d.ts +18 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -1
  4. package/dist/src/admission/admit.js +16 -3
  5. package/dist/src/admission/admit.js.map +1 -1
  6. package/dist/src/agent.d.ts +208 -80
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +345 -113
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +50 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +4 -20
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/append-all.d.ts +5 -0
  17. package/dist/src/append-all.d.ts.map +1 -0
  18. package/dist/src/append-all.js +8 -0
  19. package/dist/src/append-all.js.map +1 -0
  20. package/dist/src/code-execution/isolate/scripts.d.ts +12 -8
  21. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  22. package/dist/src/code-execution/isolate/scripts.js +24 -0
  23. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  24. package/dist/src/config-checks.d.ts +7 -0
  25. package/dist/src/config-checks.d.ts.map +1 -1
  26. package/dist/src/config-checks.js +14 -0
  27. package/dist/src/config-checks.js.map +1 -1
  28. package/dist/src/context-manager-errors.d.ts +25 -0
  29. package/dist/src/context-manager-errors.d.ts.map +1 -0
  30. package/dist/src/context-manager-errors.js +31 -0
  31. package/dist/src/context-manager-errors.js.map +1 -0
  32. package/dist/src/context-options.d.ts +70 -0
  33. package/dist/src/context-options.d.ts.map +1 -0
  34. package/dist/src/context-options.js +90 -0
  35. package/dist/src/context-options.js.map +1 -0
  36. package/dist/src/cut-off-round.js +4 -4
  37. package/dist/src/deferred-import.d.ts +93 -0
  38. package/dist/src/deferred-import.d.ts.map +1 -0
  39. package/dist/src/deferred-import.js +177 -0
  40. package/dist/src/deferred-import.js.map +1 -0
  41. package/dist/src/dendrite/audience.d.ts +223 -0
  42. package/dist/src/dendrite/audience.d.ts.map +1 -0
  43. package/dist/src/dendrite/audience.js +202 -0
  44. package/dist/src/dendrite/audience.js.map +1 -0
  45. package/dist/src/dendrite/index.d.ts +5 -3
  46. package/dist/src/dendrite/index.d.ts.map +1 -1
  47. package/dist/src/dendrite/index.js +2 -1
  48. package/dist/src/dendrite/index.js.map +1 -1
  49. package/dist/src/dendrite/presets.d.ts +10 -5
  50. package/dist/src/dendrite/presets.d.ts.map +1 -1
  51. package/dist/src/dendrite/presets.js +17 -5
  52. package/dist/src/dendrite/presets.js.map +1 -1
  53. package/dist/src/dendrite/registry.d.ts +62 -7
  54. package/dist/src/dendrite/registry.d.ts.map +1 -1
  55. package/dist/src/dendrite/registry.js +183 -12
  56. package/dist/src/dendrite/registry.js.map +1 -1
  57. package/dist/src/dendrite/types.d.ts +91 -8
  58. package/dist/src/dendrite/types.d.ts.map +1 -1
  59. package/dist/src/failure-text.d.ts +36 -0
  60. package/dist/src/failure-text.d.ts.map +1 -0
  61. package/dist/src/failure-text.js +89 -0
  62. package/dist/src/failure-text.js.map +1 -0
  63. package/dist/src/foreign-rounds.d.ts +69 -0
  64. package/dist/src/foreign-rounds.d.ts.map +1 -0
  65. package/dist/src/foreign-rounds.js +152 -0
  66. package/dist/src/foreign-rounds.js.map +1 -0
  67. package/dist/src/framework.d.ts +2064 -842
  68. package/dist/src/framework.d.ts.map +1 -1
  69. package/dist/src/framework.js +8586 -3739
  70. package/dist/src/framework.js.map +1 -1
  71. package/dist/src/gate/event-gate.d.ts +125 -21
  72. package/dist/src/gate/event-gate.d.ts.map +1 -1
  73. package/dist/src/gate/event-gate.js +487 -72
  74. package/dist/src/gate/event-gate.js.map +1 -1
  75. package/dist/src/gate/index.d.ts +1 -1
  76. package/dist/src/gate/index.d.ts.map +1 -1
  77. package/dist/src/gate/index.js.map +1 -1
  78. package/dist/src/gate/reply-judge.d.ts +4 -0
  79. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  80. package/dist/src/gate/reply-judge.js +12 -3
  81. package/dist/src/gate/reply-judge.js.map +1 -1
  82. package/dist/src/gate/rule-scope.d.ts +30 -0
  83. package/dist/src/gate/rule-scope.d.ts.map +1 -0
  84. package/dist/src/gate/rule-scope.js +41 -0
  85. package/dist/src/gate/rule-scope.js.map +1 -0
  86. package/dist/src/gate/suppress-script-worker.d.ts +2 -0
  87. package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
  88. package/dist/src/gate/suppress-script-worker.js +103 -0
  89. package/dist/src/gate/suppress-script-worker.js.map +1 -0
  90. package/dist/src/gate/suppress-scripts.d.ts +105 -0
  91. package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
  92. package/dist/src/gate/suppress-scripts.js +403 -0
  93. package/dist/src/gate/suppress-scripts.js.map +1 -0
  94. package/dist/src/gate/suppression-review.d.ts +143 -0
  95. package/dist/src/gate/suppression-review.d.ts.map +1 -0
  96. package/dist/src/gate/suppression-review.js +463 -0
  97. package/dist/src/gate/suppression-review.js.map +1 -0
  98. package/dist/src/gate/suppression-stats.d.ts +125 -0
  99. package/dist/src/gate/suppression-stats.d.ts.map +1 -0
  100. package/dist/src/gate/suppression-stats.js +214 -0
  101. package/dist/src/gate/suppression-stats.js.map +1 -0
  102. package/dist/src/gate/types.d.ts +142 -7
  103. package/dist/src/gate/types.d.ts.map +1 -1
  104. package/dist/src/gate/wake-onboarding.d.ts +119 -0
  105. package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
  106. package/dist/src/gate/wake-onboarding.js +277 -0
  107. package/dist/src/gate/wake-onboarding.js.map +1 -0
  108. package/dist/src/heartbeat/heartbeat.js +1 -1
  109. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  110. package/dist/src/held-writes.d.ts +31 -23
  111. package/dist/src/held-writes.d.ts.map +1 -1
  112. package/dist/src/held-writes.js +44 -59
  113. package/dist/src/held-writes.js.map +1 -1
  114. package/dist/src/index.d.ts +6 -2
  115. package/dist/src/index.d.ts.map +1 -1
  116. package/dist/src/index.js +4 -0
  117. package/dist/src/index.js.map +1 -1
  118. package/dist/src/inference-bounds.d.ts +2 -1
  119. package/dist/src/inference-bounds.d.ts.map +1 -1
  120. package/dist/src/inference-bounds.js +9 -6
  121. package/dist/src/inference-bounds.js.map +1 -1
  122. package/dist/src/jsonrpc-envelope.d.ts +69 -0
  123. package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
  124. package/dist/src/jsonrpc-envelope.js +66 -0
  125. package/dist/src/jsonrpc-envelope.js.map +1 -0
  126. package/dist/src/mcpl/channel-registry.d.ts +188 -28
  127. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  128. package/dist/src/mcpl/channel-registry.js +426 -70
  129. package/dist/src/mcpl/channel-registry.js.map +1 -1
  130. package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
  131. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  132. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  133. package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
  134. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  135. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  136. package/dist/src/mcpl/inbound-source.d.ts +2 -1
  137. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  138. package/dist/src/mcpl/inbound-source.js +2 -1
  139. package/dist/src/mcpl/inbound-source.js.map +1 -1
  140. package/dist/src/mcpl/inference-router.d.ts +6 -6
  141. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  142. package/dist/src/mcpl/inference-router.js.map +1 -1
  143. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  144. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  145. package/dist/src/mcpl/modern-connection.js +593 -86
  146. package/dist/src/mcpl/modern-connection.js.map +1 -1
  147. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  148. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  149. package/dist/src/mcpl/protocol-family.js +15 -9
  150. package/dist/src/mcpl/protocol-family.js.map +1 -1
  151. package/dist/src/mcpl/push-coalescer.d.ts +132 -22
  152. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  153. package/dist/src/mcpl/push-coalescer.js +105 -15
  154. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  155. package/dist/src/mcpl/push-handler.d.ts +9 -7
  156. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  157. package/dist/src/mcpl/push-handler.js.map +1 -1
  158. package/dist/src/mcpl/server-connection.d.ts +50 -28
  159. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  160. package/dist/src/mcpl/server-connection.js +165 -72
  161. package/dist/src/mcpl/server-connection.js.map +1 -1
  162. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  163. package/dist/src/mcpl/tool-classes.js +3 -0
  164. package/dist/src/mcpl/tool-classes.js.map +1 -1
  165. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  166. package/dist/src/mcpl/tool-presence.js +10 -6
  167. package/dist/src/mcpl/tool-presence.js.map +1 -1
  168. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  169. package/dist/src/mcpl/toolset-host.js +7 -6
  170. package/dist/src/mcpl/toolset-host.js.map +1 -1
  171. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  172. package/dist/src/mcpl/toolsets-module.js +2 -1
  173. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  174. package/dist/src/mcpl/types.d.ts +33 -6
  175. package/dist/src/mcpl/types.d.ts.map +1 -1
  176. package/dist/src/mcpl/types.js.map +1 -1
  177. package/dist/src/module-registry.d.ts +24 -1
  178. package/dist/src/module-registry.d.ts.map +1 -1
  179. package/dist/src/module-registry.js +68 -18
  180. package/dist/src/module-registry.js.map +1 -1
  181. package/dist/src/modules/discord/index.d.ts +45 -8
  182. package/dist/src/modules/discord/index.d.ts.map +1 -1
  183. package/dist/src/modules/discord/index.js +238 -87
  184. package/dist/src/modules/discord/index.js.map +1 -1
  185. package/dist/src/modules/health/index.js +2 -2
  186. package/dist/src/modules/health/index.js.map +1 -1
  187. package/dist/src/modules/history/index.d.ts +59 -0
  188. package/dist/src/modules/history/index.d.ts.map +1 -1
  189. package/dist/src/modules/history/index.js +432 -67
  190. package/dist/src/modules/history/index.js.map +1 -1
  191. package/dist/src/modules/history/semantic.d.ts +6 -5
  192. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  193. package/dist/src/modules/history/semantic.js +18 -25
  194. package/dist/src/modules/history/semantic.js.map +1 -1
  195. package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
  196. package/dist/src/modules/workspace/disk-agreement.js +1 -1
  197. package/dist/src/modules/workspace/index.d.ts +28 -7
  198. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  199. package/dist/src/modules/workspace/index.js +80 -25
  200. package/dist/src/modules/workspace/index.js.map +1 -1
  201. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  202. package/dist/src/modules/workspace/observe.js +2 -1
  203. package/dist/src/modules/workspace/observe.js.map +1 -1
  204. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/reconcile.js +3 -2
  206. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  207. package/dist/src/notice-tags.d.ts +20 -9
  208. package/dist/src/notice-tags.d.ts.map +1 -1
  209. package/dist/src/notice-tags.js +20 -9
  210. package/dist/src/notice-tags.js.map +1 -1
  211. package/dist/src/operator-log.d.ts +36 -3
  212. package/dist/src/operator-log.d.ts.map +1 -1
  213. package/dist/src/operator-log.js +2 -2
  214. package/dist/src/operator-log.js.map +1 -1
  215. package/dist/src/presentation.d.ts +747 -0
  216. package/dist/src/presentation.d.ts.map +1 -0
  217. package/dist/src/presentation.js +1437 -0
  218. package/dist/src/presentation.js.map +1 -0
  219. package/dist/src/prose-drafts.d.ts +15 -11
  220. package/dist/src/prose-drafts.d.ts.map +1 -1
  221. package/dist/src/prose-drafts.js +16 -12
  222. package/dist/src/prose-drafts.js.map +1 -1
  223. package/dist/src/read-marks.d.ts +192 -0
  224. package/dist/src/read-marks.d.ts.map +1 -0
  225. package/dist/src/read-marks.js +242 -0
  226. package/dist/src/read-marks.js.map +1 -0
  227. package/dist/src/reasoning-loss.d.ts +5 -0
  228. package/dist/src/reasoning-loss.d.ts.map +1 -0
  229. package/dist/src/reasoning-loss.js +8 -0
  230. package/dist/src/reasoning-loss.js.map +1 -0
  231. package/dist/src/recovery/offline-branch.d.ts +91 -6
  232. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  233. package/dist/src/recovery/offline-branch.js +277 -32
  234. package/dist/src/recovery/offline-branch.js.map +1 -1
  235. package/dist/src/recovery/recover-cli.js +24 -4
  236. package/dist/src/recovery/recover-cli.js.map +1 -1
  237. package/dist/src/send-tools.d.ts +5 -9
  238. package/dist/src/send-tools.d.ts.map +1 -1
  239. package/dist/src/send-tools.js +40 -7
  240. package/dist/src/send-tools.js.map +1 -1
  241. package/dist/src/sent-rows.js +1 -1
  242. package/dist/src/stored-injections.d.ts +2 -2
  243. package/dist/src/stored-injections.js +2 -2
  244. package/dist/src/stored-removal.d.ts +17 -4
  245. package/dist/src/stored-removal.d.ts.map +1 -1
  246. package/dist/src/stored-removal.js +29 -5
  247. package/dist/src/stored-removal.js.map +1 -1
  248. package/dist/src/supplied-text.d.ts +1 -1
  249. package/dist/src/supplied-text.js +3 -3
  250. package/dist/src/supplied-text.js.map +1 -1
  251. package/dist/src/thinking-drops.d.ts +2 -0
  252. package/dist/src/thinking-drops.d.ts.map +1 -1
  253. package/dist/src/thinking-drops.js.map +1 -1
  254. package/dist/src/tool-presentation.d.ts.map +1 -1
  255. package/dist/src/tool-presentation.js +38 -9
  256. package/dist/src/tool-presentation.js.map +1 -1
  257. package/dist/src/tool-result-guard.d.ts +12 -1
  258. package/dist/src/tool-result-guard.d.ts.map +1 -1
  259. package/dist/src/tool-result-guard.js +42 -8
  260. package/dist/src/tool-result-guard.js.map +1 -1
  261. package/dist/src/tune-out/coordinator.d.ts +1170 -81
  262. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  263. package/dist/src/tune-out/coordinator.js +3732 -369
  264. package/dist/src/tune-out/coordinator.js.map +1 -1
  265. package/dist/src/tune-out/tools.d.ts +114 -17
  266. package/dist/src/tune-out/tools.d.ts.map +1 -1
  267. package/dist/src/tune-out/tools.js +89 -28
  268. package/dist/src/tune-out/tools.js.map +1 -1
  269. package/dist/src/tune-out/window-anchor.d.ts +143 -0
  270. package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
  271. package/dist/src/tune-out/window-anchor.js +202 -0
  272. package/dist/src/tune-out/window-anchor.js.map +1 -0
  273. package/dist/src/types/agent.d.ts +121 -25
  274. package/dist/src/types/agent.d.ts.map +1 -1
  275. package/dist/src/types/events.d.ts +2 -0
  276. package/dist/src/types/events.d.ts.map +1 -1
  277. package/dist/src/types/framework.d.ts +72 -17
  278. package/dist/src/types/framework.d.ts.map +1 -1
  279. package/dist/src/types/inference-log.d.ts +1 -0
  280. package/dist/src/types/inference-log.d.ts.map +1 -1
  281. package/dist/src/types/module.d.ts +180 -69
  282. package/dist/src/types/module.d.ts.map +1 -1
  283. package/dist/src/types/trace.d.ts +573 -39
  284. package/dist/src/types/trace.d.ts.map +1 -1
  285. package/package.json +4 -4
  286. package/src/admission/admit.ts +32 -3
  287. package/src/agent.ts +392 -132
  288. package/src/api/mcp-server-core.ts +54 -17
  289. package/src/api/server.ts +4 -23
  290. package/src/append-all.ts +6 -0
  291. package/src/code-execution/isolate/scripts.ts +28 -3
  292. package/src/config-checks.ts +14 -0
  293. package/src/context-manager-errors.ts +37 -0
  294. package/src/context-options.ts +106 -0
  295. package/src/cut-off-round.ts +4 -4
  296. package/src/deferred-import.ts +239 -0
  297. package/src/dendrite/audience.ts +315 -0
  298. package/src/dendrite/index.ts +14 -2
  299. package/src/dendrite/presets.ts +18 -6
  300. package/src/dendrite/registry.ts +213 -14
  301. package/src/dendrite/types.ts +92 -8
  302. package/src/failure-text.ts +88 -0
  303. package/src/foreign-rounds.ts +199 -0
  304. package/src/framework.ts +8796 -3813
  305. package/src/gate/event-gate.ts +518 -91
  306. package/src/gate/index.ts +3 -0
  307. package/src/gate/reply-judge.ts +12 -3
  308. package/src/gate/rule-scope.ts +45 -0
  309. package/src/gate/suppress-script-worker.ts +89 -0
  310. package/src/gate/suppress-scripts.ts +414 -0
  311. package/src/gate/suppression-review.ts +517 -0
  312. package/src/gate/suppression-stats.ts +314 -0
  313. package/src/gate/types.ts +148 -7
  314. package/src/gate/wake-onboarding.ts +309 -0
  315. package/src/heartbeat/heartbeat.ts +1 -1
  316. package/src/held-writes.ts +49 -60
  317. package/src/index.ts +10 -2
  318. package/src/inference-bounds.ts +11 -6
  319. package/src/jsonrpc-envelope.ts +81 -0
  320. package/src/mcpl/channel-registry.ts +513 -116
  321. package/src/mcpl/checkpoint-manager.ts +3 -2
  322. package/src/mcpl/feature-set-manager.ts +2 -1
  323. package/src/mcpl/inbound-source.ts +2 -1
  324. package/src/mcpl/inference-router.ts +7 -4
  325. package/src/mcpl/modern-connection.ts +629 -91
  326. package/src/mcpl/protocol-family.ts +15 -9
  327. package/src/mcpl/push-coalescer.ts +193 -34
  328. package/src/mcpl/push-handler.ts +10 -5
  329. package/src/mcpl/server-connection.ts +169 -75
  330. package/src/mcpl/tool-classes.ts +3 -0
  331. package/src/mcpl/tool-presence.ts +10 -5
  332. package/src/mcpl/toolset-host.ts +7 -6
  333. package/src/mcpl/toolsets-module.ts +2 -1
  334. package/src/mcpl/types.ts +33 -6
  335. package/src/module-registry.ts +73 -17
  336. package/src/modules/discord/index.ts +230 -82
  337. package/src/modules/health/index.ts +2 -2
  338. package/src/modules/history/index.ts +439 -70
  339. package/src/modules/history/semantic.ts +18 -24
  340. package/src/modules/workspace/disk-agreement.ts +1 -1
  341. package/src/modules/workspace/index.ts +89 -26
  342. package/src/modules/workspace/observe.ts +2 -1
  343. package/src/modules/workspace/reconcile.ts +3 -2
  344. package/src/notice-tags.ts +21 -9
  345. package/src/operator-log.ts +36 -3
  346. package/src/presentation.ts +1678 -0
  347. package/src/prose-drafts.ts +16 -12
  348. package/src/read-marks.ts +328 -0
  349. package/src/reasoning-loss.ts +7 -0
  350. package/src/recovery/offline-branch.ts +350 -35
  351. package/src/recovery/recover-cli.ts +23 -4
  352. package/src/send-tools.ts +40 -7
  353. package/src/sent-rows.ts +1 -1
  354. package/src/stored-injections.ts +2 -2
  355. package/src/stored-removal.ts +31 -7
  356. package/src/supplied-text.ts +3 -3
  357. package/src/thinking-drops.ts +2 -0
  358. package/src/tool-presentation.ts +34 -9
  359. package/src/tool-result-guard.ts +41 -7
  360. package/src/tune-out/coordinator.ts +4121 -472
  361. package/src/tune-out/tools.ts +162 -44
  362. package/src/tune-out/window-anchor.ts +277 -0
  363. package/src/types/agent.ts +122 -25
  364. package/src/types/events.ts +2 -0
  365. package/src/types/framework.ts +73 -17
  366. package/src/types/inference-log.ts +1 -0
  367. package/src/types/module.ts +181 -68
  368. package/src/types/trace.ts +607 -43
@@ -1,11 +1,14 @@
1
1
  /**
2
- * ModernMcpConnection: a server that speaks modern MCP (2026-07-28), reached
3
- * over stdio or Streamable HTTP through the official SDK client.
2
+ * ModernMcpConnection: a server reached through the official SDK client, over
3
+ * stdio at MCP 2026-07-28, or over Streamable HTTP at the revision negotiated
4
+ * at each connect: 2026-07-28 when the server offers it, and otherwise
5
+ * 2025-11-25, 2025-06-18 or 2025-03-26.
4
6
  *
5
- * Responsibilities divide like this. The SDK owns the modern wire:
6
- * `server/discover`, the per-request `_meta` envelope, `resultType`,
7
- * continuation of `input_required` results, cancellation, list-change
8
- * subscriptions, and the HTTP binding's headers, auth retry and
7
+ * Responsibilities divide like this. The SDK owns the wire: negotiation
8
+ * (`server/discover`, falling back to `initialize` over HTTP), the per-request
9
+ * `_meta` envelope, `resultType`, continuation of `input_required` results,
10
+ * cancellation, list-change subscriptions (2026-07-28) and notifications
11
+ * (2025), and the HTTP binding's headers, session id, auth retry and
9
12
  * header-mismatch handling. This class owns what Connectome decides:
10
13
  * - **stdio** runs through Connectome's own spawner, so the child gets the
11
14
  * same env allowlist, `inheritEnv` and stderr surfacing as a legacy server,
@@ -27,7 +30,7 @@
27
30
  * dispatch, output validation after a successful result) as it does for
28
31
  * server answers. A call's answer is the server's final one: an
29
32
  * `input_required` answer asks for another round, so a call that ends
30
- * after one has no answer.
33
+ * after one has no answer. On a 2025 revision every result is final.
31
34
  * - **Lifetime.** On a lost transport the connection restarts with the same
32
35
  * backoff settings as legacy, when `reconnect` is set, once the lost
33
36
  * launch is reaped, so a second child never runs beside the first. A
@@ -35,11 +38,26 @@
35
38
  * no loss the pipes show: each call's deadline is the only bound on it.
36
39
  * A server that shares no revision with this client is a configuration
37
40
  * verdict, as for legacy (`McplProtocolVersionError`): it is never
38
- * retried. List-change
39
- * awareness is kept while the connection lives: a subscription the server
40
- * advertises but that didn't open, or that ended, is reopened. Each connect
41
- * is a generation, so a superseded client never touches a newer one.
42
- * `close()` owns a connect in flight too.
41
+ * retried. List-change awareness is kept while the connection lives: on
42
+ * 2026-07-28, a subscription the server advertises but that didn't open,
43
+ * or that ended, is reopened; on a 2025 revision, the session's standalone
44
+ * stream, which carries its list changes, is opened and reopened while the
45
+ * session lives, with a session id or without, unless the server answers
46
+ * its first open with a status that won't pass (`mayPass`), such as a 405,
47
+ * which offers no stream. Each connect is a generation, so a superseded
48
+ * client never touches a newer one. `close()` owns a connect in flight
49
+ * too.
50
+ * - **A 2025 session over HTTP** ends when the server answers 404 to a
51
+ * request that carried its id, but for the standalone stream's first open,
52
+ * or stops serving its revision (a 400 carrying -32022) or its streams (a
53
+ * 405 to a GET for one it had served), as a server upgraded to the SDK's
54
+ * 2026 handler does (`sessionEnded`). A new connect starts at once if the
55
+ * old session had served a request ('close', then 'reconnect'), and
56
+ * negotiates afresh, unless it lived less than a base interval, as did the
57
+ * last one restarted that way; otherwise the end is a failed connect. A
58
+ * request's stream the server cut is resumed for as long as the request
59
+ * lives. Closing a generation DELETEs a session the server holds by its
60
+ * id, a failed connect's included.
43
61
  *
44
62
  * Nothing here is MCPL: a modern server has no grant, no planes and no
45
63
  * server→host requests. The framework uses this connection only through its
@@ -59,24 +77,51 @@ import {
59
77
  parseJSONRPCMessage,
60
78
  type AuthProvider,
61
79
  type CallToolResult,
80
+ type FetchLike,
62
81
  type JSONRPCMessage,
63
82
  type McpSubscription,
64
83
  type MessageExtraInfo,
84
+ type ReconnectionScheduler,
65
85
  type Transport,
66
86
  type TransportSendOptions,
67
87
  } from '@modelcontextprotocol/client';
68
88
 
69
89
  import { HeldStderr, STDERR_END_WAIT_MS, StdioTransport, type McplTransport, type TransportCloseInfo } from './transport.js';
70
90
  import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, withStderr } from './server-connection.js';
71
- import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, logDroppedLine, maskedError, noteDialSent, redactServerText, redactedData, serverTextRedactor, type ServerTextRedactor } from './protocol-family.js';
91
+ import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_HTTP_PROTOCOL_VERSIONS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, logDroppedLine, maskedError, noteDialSent, redactServerText, redactedData, serverTextRedactor, type ServerTextRedactor } from './protocol-family.js';
72
92
  import type { McplServerConfig, McpToolDefinition } from './types.js';
73
93
  import { boundedForLog } from './errors.js';
74
94
  import { logDetail, logValue, logValueRedacted, loggedCode } from './log-value.js';
75
95
 
76
96
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
77
- /** Bound on establishing the connection (`server/discover`), like the legacy
78
- * engine's initialize timeout. */
97
+ /** Bound on each request of establishing the connection (`server/discover`,
98
+ * and over HTTP `initialize`), like the legacy engine's initialize timeout. */
79
99
  const CONNECT_TIMEOUT_MS = 30_000;
100
+ const DELETE_TIMEOUT_MS = 3_000;
101
+ const HTTP_2025_REVISIONS: ReadonlySet<string> = new Set(MODERN_HTTP_PROTOCOL_VERSIONS.filter((version) => version !== MODERN_MCP_PROTOCOL_VERSION));
102
+ /** StreamableHTTPClientTransport's default `maxRetries`. */
103
+ const SDK_STREAM_RETRIES = 2;
104
+ const SESSION_END = {
105
+ ended: 'ended its session (HTTP 404)',
106
+ revision: "refused its session's revision (HTTP 400, -32022)",
107
+ stream: "stopped serving its session's streams (HTTP 405)",
108
+ } as const;
109
+ /**
110
+ * An answer to the standalone stream's first open that may pass, so it is
111
+ * tried again (see `firstOpen`): a timeout (408), a stream the server still
112
+ * holds for this session from a try whose answer never reached us (409), a
113
+ * rate limit (429), or a server error, except 501 and 505, which say this
114
+ * server never will.
115
+ */
116
+ const mayPass = (status: number): boolean =>
117
+ status === 408 || status === 409 || status === 429 || (status >= 500 && status !== 501 && status !== 505);
118
+ /**
119
+ * The reason a call whose stream was lost fails with (see WireRecord's
120
+ * `cancel`). An SdkError, so the SDK passes it on as it is, where it reads
121
+ * any other abort reason as a timeout.
122
+ */
123
+ const lostStream = (): SdkError =>
124
+ new SdkError(SdkErrorCode.ConnectionClosed, 'the stream that would carry its answer ended, and could not be resumed');
80
125
  /** Inventory listing has its own bound: it is host work, not a resident's call. */
81
126
  const LIST_TIMEOUT_MS = 30_000;
82
127
 
@@ -96,7 +141,8 @@ export interface ModernToolCallResult {
96
141
  * header-mismatch retry, or the rounds of an `input_required` continuation.
97
142
  * Only an error or a `complete` result is the server's final answer.
98
143
  * `input-required` asks for another round, and a result of any other type,
99
- * or of none (`unrecognized`), doesn't say the call finished.
144
+ * or of none (`unrecognized`), doesn't say the call finished. On a 2025
145
+ * revision, whose results carry no type, every result is final.
100
146
  */
101
147
  interface WireRecord {
102
148
  readonly method: string;
@@ -109,6 +155,12 @@ interface WireRecord {
109
155
  * result): the server may have acted on the call, whatever a later leg
110
156
  * meets. A new leg's send never clears it. */
111
157
  continued: boolean;
158
+ /** Read at the HTTP layer (`observedFetch`), since ending the session
159
+ * closes the client, which can fail the call before the transport's own
160
+ * error reaches it: `ended` for a 404, `revision` for a 400 carrying
161
+ * -32022. */
162
+ sessionEnded?: 'ended' | 'revision';
163
+ readonly cancel: AbortController;
112
164
  }
113
165
 
114
166
  class NotWrittenError extends Error {
@@ -120,10 +172,25 @@ class NotWrittenError extends Error {
120
172
  }
121
173
  }
122
174
 
123
- /** The logical request a send belongs to. The SDK hands each request to the
124
- * transport inside the caller's async chain, so the scope reaches `send`. */
175
+ /**
176
+ * The logical request a send belongs to. The SDK hands each request to the
177
+ * transport inside the caller's async chain, so the scope reaches `send` and
178
+ * the request's POST (see observedFetch). Anything else started on that chain
179
+ * carries the scope too: a session's end queued from a call's fetch restarts
180
+ * the session inside that call's scope, the new session's requests included.
181
+ * So a POST's scope alone doesn't make it that call's: its body's id must be
182
+ * the call's current leg.
183
+ */
125
184
  const wireScope = new AsyncLocalStorage<WireRecord>();
126
185
 
186
+ interface NegotiationEvidence {
187
+ listed?: { versions: string[]; code?: number; data?: unknown };
188
+ initialize?: { version: string | null } | { refused: true };
189
+ }
190
+
191
+ const stringList = (value: unknown): string[] | undefined =>
192
+ Array.isArray(value) && value.every((v) => typeof v === 'string') ? value as string[] : undefined;
193
+
127
194
  /** A send in flight, its transport, and what that transport reported
128
195
  * meanwhile, held until the send settles (ObservedTransport.send). A
129
196
  * scope that names no transport, a notification's or a settled send's,
@@ -141,8 +208,31 @@ class ObservedTransport implements Transport {
141
208
  onerror?: (error: Error) => void;
142
209
  onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
143
210
  private readonly awaiting = new Map<string | number, WireRecord>();
211
+ private readonly negotiating = new Map<string | number, string>();
212
+ readonly negotiation: NegotiationEvidence = {};
213
+ private established = false;
214
+ private finalResults = false;
215
+ answered = false;
216
+ /**
217
+ * Streamable HTTP stops a request's stream when the request's signal fires,
218
+ * and stops resuming it. The SDK gives a request such a signal only on
219
+ * 2026-07-28 over HTTP, and never cancels an unanswered `initialize`. The
220
+ * stdio transport ignores it.
221
+ */
222
+ private readonly aborts = new Map<string | number, AbortController>();
223
+
224
+ /**
225
+ * @param beforeClose runs at each close before the inner transport closes,
226
+ * while it can still send: an HTTP session's DELETE. The SDK closes the
227
+ * transport through this boundary when a handshake fails, before the error
228
+ * reaches the connection.
229
+ */
230
+ constructor(private readonly inner: Transport, private readonly beforeClose?: () => Promise<void>) {}
144
231
 
145
- constructor(private readonly inner: Transport) {}
232
+ establish(era: 'modern' | 'legacy'): void {
233
+ this.established = true;
234
+ this.finalResults = era === 'legacy';
235
+ }
146
236
 
147
237
  get sessionId(): string | undefined {
148
238
  return this.inner.sessionId;
@@ -162,6 +252,11 @@ class ObservedTransport implements Transport {
162
252
 
163
253
  async start(): Promise<void> {
164
254
  this.inner.onmessage = (message, extra) => {
255
+ if (typeof message === 'object' && message !== null && 'id' in message && ('result' in message || 'error' in message)) {
256
+ this.noteNegotiation(message);
257
+ if (message.id !== undefined) this.aborts.delete(message.id);
258
+ if (this.established) this.answered = true;
259
+ }
165
260
  // An answer to a request in scope is handed to the SDK inside that
166
261
  // request's scope. The SDK drives an input_required continuation from
167
262
  // this very callback, outside the caller's async chain, and the next
@@ -185,6 +280,18 @@ class ObservedTransport implements Transport {
185
280
  }
186
281
 
187
282
  async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
283
+ if ('method' in message && 'id' in message && (message.method === 'server/discover' || message.method === 'initialize')) {
284
+ this.negotiating.set(message.id, message.method);
285
+ }
286
+ if ('method' in message && message.method === 'notifications/cancelled') {
287
+ // The request is over for the SDK, which rejected it as it cancelled:
288
+ // its stream stops now, whatever becomes of this notification's POST.
289
+ const id = (message.params as { requestId?: unknown } | undefined)?.requestId;
290
+ if (typeof id === 'string' || typeof id === 'number') {
291
+ this.aborts.get(id)?.abort();
292
+ this.aborts.delete(id);
293
+ }
294
+ }
188
295
  if ('method' in message && !('id' in message)) {
189
296
  // A notification's sender can swallow what its send throws (the SDK's
190
297
  // listen teardown does), so what its transport reports is reported at
@@ -199,19 +306,42 @@ class ObservedTransport implements Transport {
199
306
  // BigInt, a cycle) never leaves this host: it's refused here, before
200
307
  // it's marked handed off (#556).
201
308
  JSON.stringify(message);
202
- const prior = { handedOff: record.handedOff, currentId: record.currentId, answer: record.answer, error: record.error };
309
+ const prior = { handedOff: record.handedOff, currentId: record.currentId, answer: record.answer, error: record.error, sessionEnded: record.sessionEnded };
203
310
  // Handed off from here on: past this point nothing proves it unsent,
204
311
  // unless the transport says it wrote nothing.
205
312
  record.handedOff = true;
206
313
  record.currentId = message.id;
207
314
  record.answer = 'none';
208
315
  record.error = undefined;
316
+ record.sessionEnded = undefined;
209
317
  this.awaiting.set(message.id, record);
210
318
  undo = () => {
211
319
  this.awaiting.delete(message.id);
212
320
  Object.assign(record, prior);
213
321
  };
214
322
  }
323
+ let sendOptions = options;
324
+ if ('method' in message && 'id' in message && options?.requestSignal === undefined) {
325
+ const abort = new AbortController();
326
+ this.aborts.set(message.id, abort);
327
+ sendOptions = { ...options, requestSignal: abort.signal };
328
+ }
329
+ if (undo && record && 'id' in message && sendOptions?.onRequestStreamEnd === undefined) {
330
+ // The transport calls this once the stream that would carry this leg's
331
+ // answer is over: after the answer, or without it, when the stream
332
+ // can't be resumed. That is a stream that ended with no event id to
333
+ // resume from, a resume answered 405 or with no body, or the
334
+ // transport's retries spent (two on 2026-07-28; on 2025 it has no
335
+ // limit). Unanswered, the call is lost, so it fails now rather than at
336
+ // its deadline. A 2026-07-28 listen brings its own.
337
+ const leg = message.id;
338
+ sendOptions = {
339
+ ...sendOptions,
340
+ onRequestStreamEnd: () => {
341
+ if (record.currentId === leg && record.answer === 'none') record.cancel.abort(lostStream());
342
+ },
343
+ };
344
+ }
215
345
  // A request's or a response's sender reports what its send throws: a
216
346
  // call through its outcome, the connect through its failure, the SDK
217
347
  // through its own report. So what the transport reports meanwhile is held
@@ -219,13 +349,15 @@ class ObservedTransport implements Transport {
219
349
  const sending: { wire: ObservedTransport | null; held: Error[] } = { wire: this, held: [] };
220
350
  let thrown: unknown;
221
351
  try {
222
- await sendScope.run(sending, () => this.inner.send(message, options));
352
+ await sendScope.run(sending, () => this.inner.send(message, sendOptions));
223
353
  } catch (error) {
224
354
  thrown = error;
225
355
  // Refused before any write: this leg never reached the server, so the
226
356
  // record is as it was before it. Any other throw (an HTTP send can fail
227
357
  // after part of the request went out) leaves it handed off.
228
358
  if (error instanceof NotWrittenError) undo?.();
359
+ // The SDK settles a request whose send failed itself: nothing cancels it.
360
+ if ('method' in message && 'id' in message) this.aborts.delete(message.id);
229
361
  throw error;
230
362
  } finally {
231
363
  // Settled: what was held goes on, but for the failure the send threw,
@@ -239,10 +371,12 @@ class ObservedTransport implements Transport {
239
371
  }
240
372
 
241
373
  async close(): Promise<void> {
374
+ await this.beforeClose?.();
242
375
  await this.inner.close();
243
376
  }
244
377
 
245
- /** Drop a finished request's entries (unanswered legs included). */
378
+ /** Drop a finished request's legs from `awaiting`, unanswered legs
379
+ * included. */
246
380
  forget(record: WireRecord): void {
247
381
  for (const [id, entry] of this.awaiting) if (entry === record) this.awaiting.delete(id);
248
382
  }
@@ -263,11 +397,36 @@ class ObservedTransport implements Transport {
263
397
  } else {
264
398
  const result = (message as { result?: unknown }).result;
265
399
  const type = typeof result === 'object' && result !== null ? (result as { resultType?: unknown }).resultType : undefined;
266
- record.answer = type === 'complete' ? 'complete' : type === 'input_required' ? 'input-required' : 'unrecognized';
400
+ record.answer = this.finalResults || type === 'complete' ? 'complete' : type === 'input_required' ? 'input-required' : 'unrecognized';
267
401
  if (record.answer !== 'complete') record.continued = true;
268
402
  }
269
403
  return record;
270
404
  }
405
+
406
+ private noteNegotiation(message: JSONRPCMessage): void {
407
+ const id = (message as { id: string | number }).id;
408
+ const step = this.negotiating.get(id);
409
+ if (step === undefined) return;
410
+ this.negotiating.delete(id);
411
+ if ('error' in message) {
412
+ if (step === 'initialize') {
413
+ this.negotiation.initialize = { refused: true };
414
+ return;
415
+ }
416
+ const { code, data } = message.error as { code: number; data?: unknown };
417
+ const versions = code === -32022 ? stringList((data as { supported?: unknown } | undefined)?.supported) : undefined;
418
+ if (versions) this.negotiation.listed = { versions, code, data };
419
+ return;
420
+ }
421
+ const result = (message as { result?: unknown }).result as Record<string, unknown> | null | undefined;
422
+ if (step === 'initialize') {
423
+ const version = result?.protocolVersion;
424
+ this.negotiation.initialize = { version: typeof version === 'string' ? version : null };
425
+ } else {
426
+ const versions = stringList(result?.supportedVersions);
427
+ if (versions) this.negotiation.listed = { versions };
428
+ }
429
+ }
271
430
  }
272
431
 
273
432
  /**
@@ -421,22 +580,68 @@ function bearerAuth(config: McplServerConfig): AuthProvider | undefined {
421
580
  };
422
581
  }
423
582
 
583
+ /** What an HTTP connection's own requests showed at the HTTP layer, which the
584
+ * transport doesn't report (see `observedFetch`). */
585
+ interface HttpObservations {
586
+ readonly transport: StreamableHTTPClientTransport;
587
+ /** The options object the transport keeps and reads at each stream retry. */
588
+ readonly reconnection: { initialReconnectionDelay: number; maxReconnectionDelay: number; reconnectionDelayGrowFactor: number; maxRetries: number };
589
+ ended: boolean;
590
+ deleting?: Promise<void>;
591
+ streamOpened: boolean;
592
+ /**
593
+ * The signal the standalone stream's GETs carry: the transport's own,
594
+ * taken from its first open, the first GET without `Last-Event-ID`. A
595
+ * request stream's resume carries another, its request's signal combined
596
+ * with the transport's, so this tells the two apart, with `Last-Event-ID`
597
+ * or without it.
598
+ */
599
+ standaloneSignal?: AbortSignal | null;
600
+ }
601
+
424
602
  /** One connect generation's client and the boundary it talks through. */
425
603
  interface Session {
426
604
  readonly generation: number;
427
605
  readonly client: Client;
428
606
  readonly wire: ObservedTransport;
607
+ readonly http?: HttpObservations;
429
608
  /** Its stdio transport, under `wire`; null over HTTP. */
430
609
  readonly stdio: SpawnerStdioTransport | null;
431
610
  /** Its transport has closed, whether or not the session was adopted yet. */
432
611
  lost: boolean;
433
612
  }
434
613
 
614
+ function bodyId(body: unknown): string | number | undefined {
615
+ if (typeof body !== 'string') return undefined;
616
+ try {
617
+ const id = (JSON.parse(body) as { id?: unknown } | null)?.id;
618
+ return typeof id === 'string' || typeof id === 'number' ? id : undefined;
619
+ } catch {
620
+ return undefined;
621
+ }
622
+ }
623
+
624
+ /** The JSON-RPC error -32022 (unsupported protocol version) that a 400
625
+ * carries, read from a copy so the SDK still reads the body itself. */
626
+ async function revisionRefusal(response: Response): Promise<{ code: number; message: string; data?: unknown } | undefined> {
627
+ try {
628
+ const error = (await response.clone().json() as { error?: { code?: unknown; message?: unknown; data?: unknown } } | null)?.error;
629
+ if (error?.code !== -32022) return undefined;
630
+ return { code: -32022, message: typeof error.message === 'string' ? error.message : '', ...(error.data !== undefined ? { data: error.data } : {}) };
631
+ } catch {
632
+ return undefined;
633
+ }
634
+ }
635
+
636
+ const joinVersions = (versions: readonly string[], conjunction: 'or' | 'and'): string =>
637
+ versions.length < 2 ? versions.join('') : `${versions.slice(0, -1).join(', ')} ${conjunction} ${versions[versions.length - 1]}`;
638
+
435
639
  export class ModernMcpConnection extends EventEmitter {
436
640
  readonly id: string;
437
641
  readonly family = 'modern' as const;
438
642
  readonly transportKind: 'stdio' | 'http';
439
- /** The pinned revision, once a connect has established it; null before. */
643
+ /** The revision the last connect established (over HTTP, the one it
644
+ * negotiated); null before any has. */
440
645
  protocolVersion: string | null = null;
441
646
 
442
647
  private session: Session | null = null;
@@ -449,6 +654,10 @@ export class ModernMcpConnection extends EventEmitter {
449
654
  private readonly reconnectMaxIntervalMs: number;
450
655
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
451
656
  private reconnectAttempts = 0;
657
+ private restarting = false;
658
+ private adoptedAt = 0;
659
+ private youngRestartFree = true;
660
+ private stopped = false;
452
661
  /** At most one pending reopen, and only ever for the current generation. */
453
662
  private relisten: { generation: number; timer: ReturnType<typeof setTimeout> } | null = null;
454
663
  private relistenAttempts = 0;
@@ -511,17 +720,24 @@ export class ModernMcpConnection extends EventEmitter {
511
720
  return this.session !== null;
512
721
  }
513
722
 
514
- /** Whether a lost connection will be re-established in the background. */
723
+ /** Whether a lost connection will be re-established in the background:
724
+ * with `reconnect` set, or while a new session starts in place of one the
725
+ * server ended. */
515
726
  get willReconnect(): boolean {
516
- return this.reconnectEnabled && !this.closedByHost && this.unreaped.length === 0;
727
+ return (this.reconnectEnabled || this.restarting) && !this.closedByHost && this.unreaped.length === 0;
517
728
  }
518
729
 
519
730
  /**
520
- * Stop reconnecting, closing nothing, as the legacy engine's does.
731
+ * Stop reconnecting, as the legacy engine's does, leaving the live session
732
+ * open. A session the server ends from then on isn't restarted either (see
733
+ * {@link sessionEnded}), and a reconnect or restart still connecting
734
+ * doesn't come up: its new session is closed.
521
735
  * @internal For the framework's stop(); not a host API.
522
736
  */
523
737
  stopReconnecting(): void {
524
738
  this.reconnectEnabled = false;
739
+ this.restarting = false;
740
+ this.stopped = true;
525
741
  }
526
742
 
527
743
  /** Whether close() has been called: closed for good, or closing. A
@@ -658,20 +874,41 @@ export class ModernMcpConnection extends EventEmitter {
658
874
  // reopen backs off from the base interval, not from the previous
659
875
  // generation's failures.
660
876
  this.relistenAttempts = 0;
661
- const stdio = this.transportKind === 'stdio'
662
- ? new SpawnerStdioTransport(
877
+ let session!: Session;
878
+ let http: HttpObservations | undefined;
879
+ let stdio: SpawnerStdioTransport | null = null;
880
+ let inner: Transport;
881
+ if (this.transportKind === 'stdio') {
882
+ stdio = new SpawnerStdioTransport(
663
883
  this.config,
664
884
  (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); },
665
885
  (reason, failure) => { if (this.isCurrent(generation)) this.handleLost(generation, reason, failure); },
666
- )
667
- : null;
668
- const inner: Transport = stdio ?? new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) });
669
- const wire = new ObservedTransport(inner);
886
+ );
887
+ inner = stdio;
888
+ } else {
889
+ // The transport's own backoff is zeroed, so the delay it hands
890
+ // `streamRetry` is the server's retry hint alone, and `streamRetry`
891
+ // applies the connection's backoff. The retry limit is set once the
892
+ // era settles (see establish): the transport keeps this object and
893
+ // reads `maxRetries` from it at each retry.
894
+ const reconnection = { initialReconnectionDelay: 0, maxReconnectionDelay: 0, reconnectionDelayGrowFactor: 1, maxRetries: Infinity };
895
+ const transport = new StreamableHTTPClientTransport(new URL(this.config.url!), {
896
+ authProvider: bearerAuth(this.config),
897
+ fetch: this.observedFetch(() => session),
898
+ reconnectionOptions: reconnection,
899
+ reconnectionScheduler: this.streamRetry,
900
+ });
901
+ http = { transport, reconnection, streamOpened: false, ended: false };
902
+ inner = transport;
903
+ }
904
+ const wire = new ObservedTransport(inner, http ? () => ModernMcpConnection.endSession(session) : undefined);
670
905
 
671
906
  const client = new Client(
672
907
  { name: 'agent-framework', version: '1.0.0' },
673
908
  {
674
- versionNegotiation: { mode: { pin: MODERN_MCP_PROTOCOL_VERSION } },
909
+ ...(this.transportKind === 'http'
910
+ ? { versionNegotiation: { mode: 'auto' as const }, supportedProtocolVersions: [...MODERN_HTTP_PROTOCOL_VERSIONS] }
911
+ : { versionNegotiation: { mode: { pin: MODERN_MCP_PROTOCOL_VERSION } } }),
675
912
  // Nothing advertised: this client supplies no sampling, elicitation
676
913
  // or roots, so a conforming server cannot require them.
677
914
  capabilities: {},
@@ -684,7 +921,7 @@ export class ModernMcpConnection extends EventEmitter {
684
921
  },
685
922
  },
686
923
  );
687
- const session: Session = { generation, client, wire, stdio, lost: false };
924
+ session = { generation, client, wire, stdio, http, lost: false };
688
925
  client.onerror = (error: Error) => {
689
926
  // The SDK reports late responses to deadline-abandoned calls here
690
927
  // ("unknown message ID"), and a list-change subscription it could not
@@ -730,10 +967,12 @@ export class ModernMcpConnection extends EventEmitter {
730
967
  } catch (error) {
731
968
  const failure = await ModernMcpConnection.closeSession(session);
732
969
  if (failure) this.holdUnreaped(session, failure);
733
- throw await ModernMcpConnection.withStderrTail(session, this.versionVerdict(error) ?? error);
970
+ throw await ModernMcpConnection.withStderrTail(session, this.versionVerdict(error, session.wire.negotiation) ?? error);
734
971
  }
735
- if (!this.isCurrent(session.generation)) {
736
- // Superseded or closed while connecting: this generation never serves.
972
+ if (!this.isCurrent(session.generation) || this.stopped) {
973
+ // Superseded or closed while connecting, or a reconnect or restart
974
+ // still connecting when stopReconnecting() came: this generation never
975
+ // serves.
737
976
  const failure = await ModernMcpConnection.closeSession(session);
738
977
  if (failure) this.holdUnreaped(session, failure);
739
978
  return;
@@ -756,47 +995,92 @@ export class ModernMcpConnection extends EventEmitter {
756
995
  ? `MCP server "${this.id}": this host stopped using the server's stdio while connecting (${stopped.message})`
757
996
  : `MCP server "${this.id}" closed its connection while connecting`));
758
997
  }
998
+ const era = session.client.getProtocolEra() ?? 'modern';
999
+ session.wire.establish(era);
1000
+ if (session.http && era === 'modern') session.http.reconnection.maxRetries = SDK_STREAM_RETRIES;
1001
+ if (this.opening?.session === session) this.opening = null;
759
1002
  this.session = session;
760
- this.protocolVersion = MODERN_MCP_PROTOCOL_VERSION;
761
- this.reconnectAttempts = 0;
762
- this.keepListening(session, session.client.autoOpenedSubscription);
1003
+ this.adoptedAt = performance.now();
1004
+ this.protocolVersion = session.client.getNegotiatedProtocolVersion() ?? null;
1005
+ if (!session.http || era === 'modern') this.reconnectAttempts = 0;
1006
+ // On a 2025 revision list changes arrive unsolicited, on the session's
1007
+ // standalone stream: there's no subscription to keep.
1008
+ if (era === 'modern') this.keepListening(session, session.client.autoOpenedSubscription);
763
1009
  }
764
1010
 
765
1011
  /**
766
- * The server refused the pinned revision (no common one in its
767
- * `server/discover` answer, or an unsupported-version error): a
768
- * configuration verdict, as for the legacy engine, in the same type, so
769
- * every reader treats both engines alike. Retrying can't change it.
1012
+ * This client and the server couldn't negotiate a revision: a configuration
1013
+ * verdict, as for the legacy engine, in the same type, so every reader
1014
+ * treats both engines alike. Retrying can't change it.
770
1015
  */
771
- private versionVerdict(error: unknown): McplProtocolVersionError | null {
772
- if (!(error instanceof UnsupportedProtocolVersionError)) return null;
1016
+ private versionVerdict(error: unknown, evidence: NegotiationEvidence = {}): McplProtocolVersionError | null {
1017
+ const offered = this.transportKind === 'http' ? MODERN_HTTP_PROTOCOL_VERSIONS : [MODERN_MCP_PROTOCOL_VERSION];
773
1018
  // The server's words and data, redacted before they're bounded or kept:
774
1019
  // the error goes to the host. One build for all of them, so a
775
1020
  // configuration that can't be read is read once.
776
1021
  const redact = this.recorded.now();
777
- const data = redactedData((error as { data?: unknown }).data, redact) as { supported?: unknown } | undefined;
778
- const supported = Array.isArray(data?.supported) && data.supported.every((v) => typeof v === 'string')
779
- ? (data.supported as string[])
780
- : null;
781
1022
  // Legacy MCP is reached over stdio or WebSocket. A stdio server's
782
1023
  // family is its configuration's choice; an http(s) url is modern by its
783
1024
  // scheme, so there is nothing to reconfigure on it.
784
- const legacyHint = !supported?.includes(LEGACY_MCP_PROTOCOL_VERSION)
1025
+ const legacyHint = (supported: readonly string[] | null): string => !supported?.includes(LEGACY_MCP_PROTOCOL_VERSION)
785
1026
  ? ''
786
1027
  : this.transportKind === 'stdio'
787
1028
  ? ` It speaks legacy MCP: configure it without protocol: 'modern'.`
788
- : ` It speaks only legacy MCP, which this client reaches over stdio or WebSocket (ws://, wss://), not http(s).`;
789
- return new McplProtocolVersionError(
790
- `MCP server "${this.id}" does not support MCP ${MODERN_MCP_PROTOCOL_VERSION}` +
791
- (supported ? ` (it supports: ${boundedForLog(supported.join(', ')) || 'none listed'}).` : ` (${boundedForLog(redact(error.message))}).`) +
792
- legacyHint,
1029
+ : ` It speaks legacy MCP (${LEGACY_MCP_PROTOCOL_VERSION}), which this client reaches over stdio or WebSocket (ws://, wss://), not http(s).`;
1030
+ const rejected = (refused: readonly string[], supported: string[] | null, why: string, code?: number, data?: unknown, note = '') => new McplProtocolVersionError(
1031
+ `MCP server "${this.id}" does not support MCP ${boundedForLog(redact(joinVersions(refused, 'or')))}` +
1032
+ (supported ? ` (it supports: ${boundedForLog(redact(supported.join(', '))) || 'none listed'}).` : ` (${boundedForLog(why)}).`) +
1033
+ note + legacyHint(supported),
793
1034
  'rejected',
794
1035
  null,
795
1036
  supported,
796
- (error as { code?: unknown }).code === undefined ? undefined : Number((error as { code?: unknown }).code),
1037
+ code,
797
1038
  data,
798
- MODERN_MCP_PROTOCOL_VERSION,
1039
+ offered[0],
1040
+ offered,
799
1041
  );
1042
+ if (error instanceof UnsupportedProtocolVersionError) {
1043
+ // The SDK's own refusal ends negotiation, whatever else the server's
1044
+ // list names. Over HTTP it raises one for a -32022 whose list names a
1045
+ // revision from 2026-07-28 on that this client doesn't speak, or for a
1046
+ // second refusal of the one it asked for, and tries no initialize
1047
+ // after either, even when the list also names a 2025 revision this
1048
+ // client offers.
1049
+ const code = (error as { code?: unknown }).code;
1050
+ const data = redactedData((error as { data?: unknown }).data, redact);
1051
+ const supported = stringList((data as { supported?: unknown } | undefined)?.supported) ?? null;
1052
+ const requested = (data as { requested?: unknown } | undefined)?.requested;
1053
+ const asked = typeof requested === 'string' ? requested : MODERN_MCP_PROTOCOL_VERSION;
1054
+ const shared = supported ? offered.filter((version) => version !== asked && supported.includes(version)) : [];
1055
+ const note = shared.length > 0
1056
+ ? ` It lists ${joinVersions(shared, 'and')}, which this client also speaks, but the SDK negotiates no further after this refusal.`
1057
+ : '';
1058
+ return rejected([asked], supported, redact(error.message), code === undefined ? undefined : Number(code), data, note);
1059
+ }
1060
+ const { initialize, listed } = evidence;
1061
+ const accepted = offered.filter((version) => version !== MODERN_MCP_PROTOCOL_VERSION);
1062
+ if (initialize && 'version' in initialize && (initialize.version === null || !accepted.includes(initialize.version))) {
1063
+ const version = initialize.version === null ? null : redact(initialize.version);
1064
+ const named = version === null ? 'without naming a protocol revision' : `with MCP ${boundedForLog(version)}`;
1065
+ const where = initialize.version === LEGACY_MCP_PROTOCOL_VERSION
1066
+ ? ', which this client reaches over stdio or WebSocket (ws://, wss://), not http(s)'
1067
+ : initialize.version === MODERN_MCP_PROTOCOL_VERSION ? ', which this client negotiates through server/discover, not initialize' : '';
1068
+ return new McplProtocolVersionError(
1069
+ `MCP server "${this.id}" answered initialize ${named}${where}. Over http(s) this client's initialize accepts ${joinVersions(accepted, 'or')}.`,
1070
+ 'mismatch',
1071
+ version,
1072
+ null,
1073
+ undefined,
1074
+ undefined,
1075
+ offered[0],
1076
+ offered,
1077
+ );
1078
+ }
1079
+ const nothingElse = this.transportKind === 'stdio' || (initialize !== undefined && 'refused' in initialize);
1080
+ if (listed && nothingElse && !listed.versions.some((version) => offered.includes(version))) {
1081
+ return rejected(offered, listed.versions.map((version) => redact(version)), '', listed.code, redactedData(listed.data, redact));
1082
+ }
1083
+ return null;
800
1084
  }
801
1085
 
802
1086
  /**
@@ -857,6 +1141,201 @@ export class ModernMcpConnection extends EventEmitter {
857
1141
  }
858
1142
  }
859
1143
 
1144
+ /**
1145
+ * The HTTP transport's fetch, observed for one session. It observes a
1146
+ * 2025 connection's requests: those carrying a 2025 revision in
1147
+ * `MCP-Protocol-Version`, which the SDK sends on each request after
1148
+ * `initialize`, with a session id or without one (the id is optional). The
1149
+ * handshake's own requests and a 2026-07-28 connection's pass through. The
1150
+ * transport reports these HTTP outcomes only as errors, or not at all:
1151
+ * - A 404 to a request that carried the session's id: the server has ended
1152
+ * the session. A POST's 404 is also that request's answer: the server
1153
+ * refused it without running it. That's marked on the leg's record (its
1154
+ * body's id names the leg) before anything else runs, since ending the
1155
+ * session closes its client, which can fail the call before the
1156
+ * transport's own 404 error reaches it.
1157
+ * - The standalone stream's first open, the one GET without
1158
+ * `Last-Event-ID` before that stream has opened, is the exception. Its
1159
+ * 404 can also mean the server has no GET route at all, which a server
1160
+ * that routes only POST answers, and reading it as the session's end
1161
+ * would make such a server unreachable. It ends nothing: the SDK reports
1162
+ * it as an error and opens no stream, as it does for any refusal of that
1163
+ * open but a 405, which it takes quietly, and a 401, which its credential
1164
+ * refresh tries once more. A session that really ended
1165
+ * meets the 404 on its next POST, such as the framework's relist after
1166
+ * the connect. The SDK never retries this open, so {@link firstOpen}
1167
+ * does, on a failure that may pass.
1168
+ * - A 400 carrying the JSON-RPC error -32022 (unsupported protocol
1169
+ * version), to any request: the server no longer serves this revision,
1170
+ * as the SDK's 2026 handler answers a 2025 request when it refuses
1171
+ * legacy traffic (`legacy: 'reject'`). The connection negotiates again,
1172
+ * and settles on 2026-07-28 when the server offers it. A POST's refusal
1173
+ * is that request's answer, a JSON-RPC error, as the SDK reads such a 400
1174
+ * on 2026-07-28: marked on its record as a 404 is.
1175
+ * - A 405 to a GET for one of the session's streams that the server had
1176
+ * served: the standalone stream opening again after it had opened, or a
1177
+ * request's stream resumed, which the server gave an event id. The server
1178
+ * no longer serves this session a stream, as the SDK's 2026 handler
1179
+ * answers a 2025 session it now serves statelessly, or refuses. The
1180
+ * connection negotiates again. A first open's 405 says only that the
1181
+ * server offers no stream, so it changes nothing. Each of these three,
1182
+ * the 404, the -32022 and the 405, marks the session ended at once
1183
+ * (`http.ended`), so it gets no DELETE: the server has let it go.
1184
+ * - The standalone stream opening again, after it had opened, without
1185
+ * `Last-Event-ID`: it couldn't ask for what it missed, whether because
1186
+ * the server's events carry no ids or because the stream that dropped
1187
+ * had carried none (the SDK keeps a stream's last id only for that
1188
+ * stream). See {@link streamReopened}. A GET that carries one resumes a
1189
+ * stream, the standalone one or a request's, and the server replays
1190
+ * what that stream missed.
1191
+ * - A standalone resume the server refuses with 400, as one does for an
1192
+ * event id it no longer holds: retrying the same id can't succeed, and
1193
+ * the SDK would retry it for the session's life. The stream opens again
1194
+ * without the id instead, which is a reopen that missed what came between.
1195
+ * A request stream's resume refused that way is left to the SDK: it is
1196
+ * retried until its request's answer arrives on it, or its deadline
1197
+ * cancels it (see ObservedTransport's `aborts`).
1198
+ */
1199
+ private observedFetch(sessionOf: () => Session): FetchLike {
1200
+ return async (url, init) => {
1201
+ const headers = new Headers(init?.headers);
1202
+ const revision = headers.get('mcp-protocol-version');
1203
+ if (revision === null || !HTTP_2025_REVISIONS.has(revision)) return fetch(url, init);
1204
+ const session = sessionOf();
1205
+ const http = session.http!;
1206
+ const method = (init?.method ?? 'GET').toUpperCase();
1207
+ const resuming = headers.has('last-event-id');
1208
+ if (method === 'GET' && !resuming && http.standaloneSignal === undefined) http.standaloneSignal = init?.signal ?? null;
1209
+ const standalone = method === 'GET' && (init?.signal ?? null) === http.standaloneSignal;
1210
+ const firstOpen = standalone && !http.streamOpened;
1211
+ let response = firstOpen ? await this.firstOpen(session, url, init) : await fetch(url, init);
1212
+ let refusal = response.status === 400 ? await revisionRefusal(response) : undefined;
1213
+ let replayed = resuming;
1214
+ if (!refusal && resuming && standalone && response.status === 400) {
1215
+ await response.body?.cancel().catch(() => {});
1216
+ headers.delete('last-event-id');
1217
+ response = await fetch(url, { ...init, headers });
1218
+ replayed = false;
1219
+ refusal = response.status === 400 ? await revisionRefusal(response) : undefined;
1220
+ }
1221
+ if (refusal) {
1222
+ const record = method === 'POST' ? wireScope.getStore() : undefined;
1223
+ if (record?.currentId !== undefined && bodyId(init?.body) === record.currentId) {
1224
+ record.answer = 'error';
1225
+ record.error = refusal;
1226
+ record.sessionEnded = 'revision';
1227
+ }
1228
+ http.ended = true;
1229
+ queueMicrotask(() => this.sessionEnded(session, 'revision'));
1230
+ return response;
1231
+ }
1232
+ if (response.status === 404 && !firstOpen && headers.has('mcp-session-id')) {
1233
+ const record = method === 'POST' ? wireScope.getStore() : undefined;
1234
+ if (record?.currentId !== undefined && bodyId(init?.body) === record.currentId) record.sessionEnded = 'ended';
1235
+ http.ended = true;
1236
+ queueMicrotask(() => this.sessionEnded(session, 'ended'));
1237
+ } else if (method === 'GET' && response.status === 405 && (standalone ? http.streamOpened : resuming)) {
1238
+ http.ended = true;
1239
+ queueMicrotask(() => this.sessionEnded(session, 'stream'));
1240
+ } else if (standalone && response.ok) {
1241
+ if (!http.streamOpened || !replayed) queueMicrotask(() => this.streamReopened(session));
1242
+ http.streamOpened = true;
1243
+ }
1244
+ return response;
1245
+ };
1246
+ }
1247
+
1248
+ /** The SDK makes the standalone stream's first open once, and never
1249
+ * retries it. */
1250
+ private async firstOpen(session: Session, url: string | URL, init: RequestInit | undefined): Promise<Response> {
1251
+ for (let failures = 1; ; failures++) {
1252
+ if (failures > 1 && !this.isCurrent(session.generation)) {
1253
+ throw new Error(`MCP server "${this.id}": the list-change stream's first open was not tried again, since the connection no longer uses this session`);
1254
+ }
1255
+ let failure: string;
1256
+ try {
1257
+ const response = await fetch(url, init);
1258
+ if (!mayPass(response.status)) return response;
1259
+ await response.body?.cancel().catch(() => {});
1260
+ failure = `HTTP ${response.status}`;
1261
+ } catch (error) {
1262
+ if (init?.signal?.aborted) throw error;
1263
+ failure = (error as Error).message;
1264
+ }
1265
+ if (this.isCurrent(session.generation) && !session.http!.ended) {
1266
+ this.emit('error', new Error(`MCP server "${this.id}" did not open its list-change stream (${redactServerText(failure, this.config)}); trying again`));
1267
+ }
1268
+ await new Promise<void>((resolve, reject) => {
1269
+ const signal = init?.signal;
1270
+ if (signal?.aborted) return reject(signal.reason);
1271
+ const timer = setTimeout(() => {
1272
+ signal?.removeEventListener('abort', aborted);
1273
+ resolve();
1274
+ }, this.backoffDelay(failures));
1275
+ timer.unref?.();
1276
+ const aborted = () => {
1277
+ clearTimeout(timer);
1278
+ reject(signal?.reason);
1279
+ };
1280
+ signal?.addEventListener('abort', aborted, { once: true });
1281
+ });
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * The HTTP transport's scheduler for trying a stream again, for both
1287
+ * streams it retries: the standalone stream and a request's stream the
1288
+ * server closed after a priming event (2025-11-25). The server's `retry`
1289
+ * hint is respected, as the spec requires. `hinted` is the server's hint
1290
+ * alone, or 0, since the transport's own backoff is zeroed.
1291
+ */
1292
+ private readonly streamRetry: ReconnectionScheduler = (reconnect, hinted, attempt) => {
1293
+ const wait = attempt === 0 && hinted > 0 ? hinted : Math.max(hinted, this.backoffDelay(attempt + 1));
1294
+ const timer = setTimeout(reconnect, Math.min(wait, MAX_TIMER_MS));
1295
+ timer.unref?.();
1296
+ return () => clearTimeout(timer);
1297
+ };
1298
+
1299
+ /** The spec has a client whose session met a 404 start a new one. */
1300
+ private sessionEnded(session: Session, cause: keyof typeof SESSION_END): void {
1301
+ if (this.session !== session || !this.isCurrent(session.generation)) return;
1302
+ this.session = null;
1303
+ void ModernMcpConnection.closeSession(session);
1304
+ if (this.stopped) {
1305
+ this.emit('close', { reason: `the server ${SESSION_END[cause]}` });
1306
+ return;
1307
+ }
1308
+ const lived = performance.now() - this.adoptedAt >= this.reconnectIntervalMs;
1309
+ if (session.wire.answered && (lived || this.youngRestartFree)) {
1310
+ this.youngRestartFree = lived;
1311
+ this.reconnectAttempts = 0;
1312
+ this.restarting = true;
1313
+ try {
1314
+ this.emit('close', { reason: `the server ${SESSION_END[cause]}; a new one is starting` });
1315
+ } finally {
1316
+ void this.attemptReconnect(cause);
1317
+ }
1318
+ return;
1319
+ }
1320
+ const attempt = Math.max(1, this.reconnectAttempts);
1321
+ this.reconnectAttempts = attempt + 1;
1322
+ const when = session.wire.answered
1323
+ ? `less than ${this.reconnectIntervalMs} ms after the session started`
1324
+ : 'before the session answered a request';
1325
+ try {
1326
+ this.emit('reconnect-failed', { error: `MCP server "${this.id}" ${SESSION_END[cause]} ${when}`, attempt });
1327
+ this.emit('close', { reason: `the server ${SESSION_END[cause]} ${when}` });
1328
+ } finally {
1329
+ this.scheduleReconnect();
1330
+ }
1331
+ }
1332
+
1333
+ private streamReopened(session: Session): void {
1334
+ if (this.session !== session || !this.isCurrent(session.generation)) return;
1335
+ if (!session.client.getServerCapabilities()?.tools?.listChanged) return;
1336
+ this.emit('tools-list-changed');
1337
+ }
1338
+
860
1339
  /** The transport went away (child exit, or a closed HTTP client). */
861
1340
  private handleLost(generation: number, reason: string, unreaped?: Error, ended?: TransportCloseInfo): void {
862
1341
  const session = this.session;
@@ -917,24 +1396,30 @@ export class ModernMcpConnection extends EventEmitter {
917
1396
  }, this.backoffDelay(this.reconnectAttempts));
918
1397
  }
919
1398
 
920
- private async attemptReconnect(): Promise<void> {
1399
+ private async attemptReconnect(restart?: keyof typeof SESSION_END): Promise<void> {
921
1400
  if (!this.willReconnect || this.session) return;
922
1401
  const attempt = Math.max(1, this.reconnectAttempts);
923
1402
  try {
924
1403
  await this.open();
1404
+ if (restart) this.restarting = false;
925
1405
  if (!this.session) return;
926
- console.error(`MCP server "${this.id}" reconnected`);
1406
+ console.error(restart ? `MCP server "${this.id}" ${SESSION_END[restart]}; a new one started` : `MCP server "${this.id}" reconnected`);
927
1407
  this.emit('reconnect', { attempts: attempt });
928
1408
  } catch (error) {
1409
+ if (restart) this.restarting = false;
929
1410
  if (this.closedByHost) return;
1411
+ const reconnecting = this.reconnectEnabled;
930
1412
  this.reconnectAttempts = attempt + 1;
931
1413
  const stderrTail = stderrTailOf(error);
932
- const failed = (message: string, permanent?: true) => this.emit('reconnect-failed', {
933
- error: message,
934
- ...(stderrTail === undefined ? {} : { stderrTail }),
935
- attempt,
936
- ...(permanent ? { permanent } : {}),
937
- });
1414
+ const failed = (message: string, permanent?: true) => {
1415
+ this.emit('reconnect-failed', {
1416
+ error: message,
1417
+ ...(stderrTail === undefined ? {} : { stderrTail }),
1418
+ attempt,
1419
+ ...(permanent ? { permanent } : {}),
1420
+ });
1421
+ if (restart && !reconnecting && !this.session) this.emit('close', { reason: `the server ${SESSION_END[restart]}, and a new one could not start` });
1422
+ };
938
1423
  const halted = this.unreapedFailure();
939
1424
  if (halted) {
940
1425
  failed(`${(error as Error).message}; ${halted}`, true);
@@ -960,11 +1445,11 @@ export class ModernMcpConnection extends EventEmitter {
960
1445
  * to that call, and leaves the call's evidence alone.
961
1446
  */
962
1447
  async listTools(): Promise<McpToolDefinition[]> {
963
- const { tools } = await this.request('tools/list', 'tools/list', (client) =>
1448
+ const { tools } = await this.request('tools/list', 'tools/list', (client, signal) =>
964
1449
  client.listTools(undefined, {
965
1450
  cacheMode: 'refresh',
966
1451
  timeout: LIST_TIMEOUT_MS,
967
- signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
1452
+ signal,
968
1453
  }), LIST_TIMEOUT_MS);
969
1454
  return tools.map((tool) => ({
970
1455
  name: tool.name,
@@ -978,14 +1463,15 @@ export class ModernMcpConnection extends EventEmitter {
978
1463
  * Call a tool within the configured deadline. One budget covers the whole
979
1464
  * logical call: the SDK's per-leg timer and its `maxTotalTimeout` across
980
1465
  * continuation rounds, and an abort signal around any retry the SDK makes
981
- * before dispatch (an auth refresh, or a header-mismatch relist).
1466
+ * before dispatch (an auth refresh, or a header-mismatch relist). That
1467
+ * signal also fires when the call's stream is lost (WireRecord's `cancel`).
982
1468
  */
983
1469
  async callTool(name: string, args: Record<string, unknown>): Promise<ModernToolCallResult> {
984
1470
  const timeoutMs = this.requestTimeoutMs;
985
- const result = await this.request('tools/call', `tools/call ${logValue(name)}`, (client) =>
1471
+ const result = await this.request('tools/call', `tools/call ${logValue(name)}`, (client, signal) =>
986
1472
  client.callTool(
987
1473
  { name, arguments: args },
988
- { timeout: timeoutMs, maxTotalTimeout: timeoutMs, signal: AbortSignal.timeout(timeoutMs) },
1474
+ { timeout: timeoutMs, maxTotalTimeout: timeoutMs, signal },
989
1475
  ), timeoutMs);
990
1476
  return {
991
1477
  content: result.content ?? [],
@@ -998,7 +1484,7 @@ export class ModernMcpConnection extends EventEmitter {
998
1484
  /** Run one logical request in a wire scope, and classify its failure by
999
1485
  * what that scope saw. `what` names the request in the host's log: its
1000
1486
  * method, and a tool call's tool as a log field. */
1001
- private async request<T>(method: string, what: string, run: (client: Client) => Promise<T>, timeoutMs?: number): Promise<T> {
1487
+ private async request<T>(method: string, what: string, run: (client: Client, signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
1002
1488
  const session = this.session;
1003
1489
  if (!session) {
1004
1490
  // Connectome refuses before anything reaches the SDK: provably not
@@ -1014,12 +1500,18 @@ export class ModernMcpConnection extends EventEmitter {
1014
1500
  'not-sent',
1015
1501
  );
1016
1502
  }
1017
- const record: WireRecord = { method, handedOff: false, answer: 'none', continued: false };
1503
+ const record: WireRecord = { method, handedOff: false, answer: 'none', continued: false, cancel: new AbortController() };
1504
+ // The deadline aborts with its own reason, a timeout, which the SDK reads
1505
+ // as one.
1506
+ const deadline = AbortSignal.timeout(timeoutMs);
1507
+ const atDeadline = () => record.cancel.abort(deadline.reason);
1508
+ deadline.addEventListener('abort', atDeadline, { once: true });
1018
1509
  try {
1019
- return await wireScope.run(record, () => run(session.client));
1510
+ return await wireScope.run(record, () => run(session.client, record.cancel.signal));
1020
1511
  } catch (error) {
1021
1512
  throw this.requestFailure(what, record, error, timeoutMs, session.lost);
1022
1513
  } finally {
1514
+ deadline.removeEventListener('abort', atDeadline);
1023
1515
  session.wire.forget(record);
1024
1516
  }
1025
1517
  }
@@ -1042,6 +1534,29 @@ export class ModernMcpConnection extends EventEmitter {
1042
1534
  return failure;
1043
1535
  }
1044
1536
 
1537
+ /**
1538
+ * End an HTTP session on the server (a DELETE), as the spec asks of a
1539
+ * client that no longer needs it. It runs before the inner transport
1540
+ * closes, since that close would abort it. When a connect fails after its
1541
+ * `initialize` was answered, the SDK closes the transport itself before
1542
+ * the error reaches `establish`. The transport sends nothing for a session
1543
+ * without an id, and the SDK reports a DELETE's failure through the
1544
+ * transport's onerror.
1545
+ */
1546
+ private static endSession(session: Session): Promise<void> {
1547
+ const http = session.http;
1548
+ if (!http || http.ended) return Promise.resolve();
1549
+ http.deleting ??= (async () => {
1550
+ let timer: ReturnType<typeof setTimeout> | undefined;
1551
+ await Promise.race([
1552
+ http.transport.terminateSession().catch(() => { /* the server expires it */ }),
1553
+ new Promise<void>((resolve) => { timer = setTimeout(resolve, DELETE_TIMEOUT_MS); }),
1554
+ ]);
1555
+ clearTimeout(timer);
1556
+ })();
1557
+ return http.deleting;
1558
+ }
1559
+
1045
1560
  /** The close in flight, which later calls wait behind. */
1046
1561
  private closing: Promise<void> | null = null;
1047
1562
 
@@ -1147,13 +1662,17 @@ export class ModernMcpConnection extends EventEmitter {
1147
1662
  * rounds cap). Nothing is in flight then, so nothing is cancelled. A
1148
1663
  * result of a type this client doesn't recognize ends the call where it
1149
1664
  * is;
1150
- * - an HTTP 401/403 refusal of the current leg: `error-response`, the
1151
- * status in `data.httpStatus` (the server answered at the HTTP layer,
1152
- * outside the JSON-RPC code space). After an earlier leg
1153
- * asked for another round it is `no-response` instead: the server may
1154
- * already have acted on the call;
1155
- * - handed off and unanswered: `no-response`. At the deadline, cancellation
1156
- * was requested and the outcome is unknown.
1665
+ * - an HTTP refusal of the current leg: `error-response`, the status in
1666
+ * `data.httpStatus` (the server answered at the HTTP layer, outside the
1667
+ * JSON-RPC code space). That's a 401 or 403, or a 404 to a POST from a
1668
+ * server that had ended the session (`sessionEnded`, read at the HTTP
1669
+ * layer, whatever error the call then met as its session closed). After
1670
+ * an earlier leg asked for another round it is `no-response` instead:
1671
+ * the server may already have acted on the call;
1672
+ * - handed off and unanswered: `no-response`, and the outcome is unknown.
1673
+ * At the deadline, cancellation was requested, and so it was when the
1674
+ * stream that would carry the answer ended unresumed (WireRecord's
1675
+ * `cancel`).
1157
1676
  *
1158
1677
  * The message is the agent's, in the legacy engine's words where it has
1159
1678
  * words for the same outcome (#506), and in their shape where it hasn't:
@@ -1200,6 +1719,13 @@ export class ModernMcpConnection extends EventEmitter {
1200
1719
  // and the message are redacted there, whatever JSON values they are,
1201
1720
  // as every other line this connection writes is.
1202
1721
  log(`answered ${what} with error ${loggedCode(code, redact)}: ${logValueRedacted(wireMessage, redact)}`);
1722
+ if (record.sessionEnded === 'revision') {
1723
+ return new McplRequestError(
1724
+ `${Server} refused this session's revision, so the request didn't run. ${this.retryText()}`,
1725
+ 'error-response',
1726
+ redactedData(data, redact),
1727
+ );
1728
+ }
1203
1729
  // The server's own words, redacted and then bounded as errors.ts says,
1204
1730
  // since they're in an error the caller gets (redactServerText's
1205
1731
  // scope), and its data's strings redacted too. It's trimmed after it's
@@ -1233,17 +1759,21 @@ export class ModernMcpConnection extends EventEmitter {
1233
1759
  'no-response',
1234
1760
  );
1235
1761
  }
1236
- // An HTTP refusal of the credential: 401 or 403, however the SDK raised
1237
- // it. It raises a plain 403 under another code than its step-up limit's,
1238
- // and an insufficient_scope 403 as InsufficientScopeError when no OAuth
1239
- // provider is configured. A 403 can refuse a valid credential that lacks
1762
+ // An HTTP refusal: the session's end, read at the HTTP layer, or the
1763
+ // credential's, 401 or 403, however the SDK raised it. It raises a plain
1764
+ // 403 under another code than its step-up limit's, and an
1765
+ // insufficient_scope 403 as InsufficientScopeError when no OAuth provider
1766
+ // is configured. A 403 can refuse a valid credential that lacks
1240
1767
  // permission, so the message says the server refused this host access.
1241
- const refusedWith = error instanceof SdkHttpError && (error.status === 401 || error.status === 403)
1242
- ? error.status
1243
- : error instanceof InsufficientScopeError ? 403 : null;
1768
+ const refusedWith = record.sessionEnded === 'ended'
1769
+ ? 404
1770
+ : error instanceof SdkHttpError && (error.status === 401 || error.status === 403)
1771
+ ? error.status
1772
+ : error instanceof InsufficientScopeError ? 403 : null;
1244
1773
  if (refusedWith !== null) {
1774
+ const why = refusedWith === 404 ? "the server had ended this connection's session" : logDetail(message);
1245
1775
  if (record.continued) {
1246
- log(`refused a later round of ${what}: HTTP ${refusedWith} (${logDetail(message)})`);
1776
+ log(`refused a later round of ${what}: HTTP ${refusedWith} (${why})`);
1247
1777
  return new McplRequestError(
1248
1778
  listing
1249
1779
  ? unknown(`${Server} refused this host access partway through the request, after asking for more`)
@@ -1252,7 +1782,15 @@ export class ModernMcpConnection extends EventEmitter {
1252
1782
  'no-response',
1253
1783
  );
1254
1784
  }
1255
- log(`refused ${what}: HTTP ${refusedWith} (${logDetail(message)})`);
1785
+ if (refusedWith === 404) {
1786
+ log(`refused ${what}: HTTP 404 (${why})`);
1787
+ return new McplRequestError(
1788
+ `${Server} had ended this connection's session, so the request didn't run. ${this.retryText()}`,
1789
+ 'error-response',
1790
+ { httpStatus: 404 },
1791
+ );
1792
+ }
1793
+ log(`refused ${what}: HTTP ${refusedWith} (${why})`);
1256
1794
  // The response body (the SDK's `text`) is the server's text and can
1257
1795
  // echo the request's url, so it's left out of the data; the status stays.
1258
1796
  const { text: _body, ...data } = (error instanceof SdkHttpError && error.data && typeof error.data === 'object' ? error.data : {}) as Record<string, unknown>;