chainlesschain 0.162.197 → 0.162.200

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 (233) hide show
  1. package/bin/chainlesschain.js +14 -6
  2. package/package.json +4 -1
  3. package/scripts/command-lifecycle-report.mjs +94 -0
  4. package/src/assets/web-panel/assets/{AIOps-D3Gu1a5d.js → AIOps-DcQ0bWKy.js} +1 -1
  5. package/src/assets/web-panel/assets/{ActionButton-DrwAoKaz.js → ActionButton-GecwfFPt.js} +1 -1
  6. package/src/assets/web-panel/assets/{Analytics-C7Q-gd_3.js → Analytics-CD6iSt9v.js} +3 -3
  7. package/src/assets/web-panel/assets/{AppLayout-DgBtae-n.js → AppLayout-CDEtuEYf.js} +5 -5
  8. package/src/assets/web-panel/assets/{Artifacts-pQ6LcA-W.js → Artifacts-DS3jAjeW.js} +1 -1
  9. package/src/assets/web-panel/assets/{Audit-DK61tocB.js → Audit-CcSJA048.js} +1 -1
  10. package/src/assets/web-panel/assets/{BackgroundAgents-BOKgDFRh.js → BackgroundAgents-DRo9Morb.js} +1 -1
  11. package/src/assets/web-panel/assets/{Backup-Bo9G4vTP.js → Backup-Vw5MrWft.js} +1 -1
  12. package/src/assets/web-panel/assets/{BaseInput-Do_dE9_v.js → BaseInput-DNHIu823.js} +1 -1
  13. package/src/assets/web-panel/assets/{Chat-COVvZzOL.js → Chat-DvAsO8-b.js} +4 -4
  14. package/src/assets/web-panel/assets/ChatBubbleRenderer-9aDuObay.js +1 -0
  15. package/src/assets/web-panel/assets/{Checkbox-Z9MJMolt.js → Checkbox-CKCa7tvn.js} +1 -1
  16. package/src/assets/web-panel/assets/{Codegen-BVzWKVbf.js → Codegen-CIFvZdaT.js} +1 -1
  17. package/src/assets/web-panel/assets/{Col-C8MnYcbT.js → Col-C79yz7bK.js} +1 -1
  18. package/src/assets/web-panel/assets/{Community-DQxqw15l.js → Community-DVvZskLJ.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compact-BAUmrh3y.js → Compact-oaew61hV.js} +1 -1
  20. package/src/assets/web-panel/assets/{Compliance-jPAkQ3zK.js → Compliance-DqA7wmRx.js} +1 -1
  21. package/src/assets/web-panel/assets/{Cowork-BBQyxbh8.js → Cowork-B-qGadxa.js} +3 -3
  22. package/src/assets/web-panel/assets/{Cron-W5koq8Z5.js → Cron-CUlCl4sS.js} +2 -2
  23. package/src/assets/web-panel/assets/{Crosschain-BQhnu5hS.js → Crosschain-CNOlr9DU.js} +1 -1
  24. package/src/assets/web-panel/assets/{DID-DD7PlI18.js → DID-DJqFCT_3.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dashboard-w_ILuwKI.js → Dashboard-CznGpphf.js} +2 -2
  26. package/src/assets/web-panel/assets/{Dropdown-qiDUAyAo.js → Dropdown-_cJw218E.js} +1 -1
  27. package/src/assets/web-panel/assets/{EmailListRenderer-PDeBcI5H.js → EmailListRenderer-Dr-1BZ9C.js} +1 -1
  28. package/src/assets/web-panel/assets/{FamilyGuardDashboard-SuArscRM.js → FamilyGuardDashboard-Dxf4xtSk.js} +1 -1
  29. package/src/assets/web-panel/assets/{Federation-BCovZaUn.js → Federation-DOiG3i_1.js} +1 -1
  30. package/src/assets/web-panel/assets/{FormItemContext-DjlFdVFk.js → FormItemContext-BzbMcFB9.js} +1 -1
  31. package/src/assets/web-panel/assets/{GenericCardRenderer-DoEZ7fcS.js → GenericCardRenderer-VbBrYttP.js} +1 -1
  32. package/src/assets/web-panel/assets/{Git-CV0-6GPI.js → Git-CLG8E6ED.js} +2 -2
  33. package/src/assets/web-panel/assets/{Governance-B_SFN50w.js → Governance-LGJQfk_q.js} +1 -1
  34. package/src/assets/web-panel/assets/{Inference-heI0Eo6k.js → Inference-DlQKx9lF.js} +1 -1
  35. package/src/assets/web-panel/assets/{KnowledgeGraph-Cz5INS5p.js → KnowledgeGraph-DU8vEouV.js} +1 -1
  36. package/src/assets/web-panel/assets/{Logs-BSFxiHkg.js → Logs-FZIQOdln.js} +2 -2
  37. package/src/assets/web-panel/assets/MarkdownRenderer-BJNWIrbw.js +1 -0
  38. package/src/assets/web-panel/assets/{Marketplace-0TWTpE7k.js → Marketplace-C_xd-zZI.js} +1 -1
  39. package/src/assets/web-panel/assets/{McpTools-CV4-wBLm.js → McpTools-q-YktphS.js} +5 -5
  40. package/src/assets/web-panel/assets/{Memory-x5bH5rw5.js → Memory-Cq9OhDin.js} +2 -2
  41. package/src/assets/web-panel/assets/{MobileBridge-DaBmUu9G.js → MobileBridge-B03_R98T.js} +1 -1
  42. package/src/assets/web-panel/assets/MobileProjects-DZrk-qmF.js +1 -0
  43. package/src/assets/web-panel/assets/{Mtc-B_zKEBif.js → Mtc-BidCKW28.js} +5 -5
  44. package/src/assets/web-panel/assets/{MtcAudit-BCfKD2yg.js → MtcAudit-r-iaMVM7.js} +2 -2
  45. package/src/assets/web-panel/assets/{Multisig-DgYfj6c_.js → Multisig-CgTUFvff.js} +3 -3
  46. package/src/assets/web-panel/assets/{NLProgramming-Dz09lFUm.js → NLProgramming-B7NvzfAV.js} +1 -1
  47. package/src/assets/web-panel/assets/{Notes-Cuj6ugBE.js → Notes-80CWNqJI.js} +3 -3
  48. package/src/assets/web-panel/assets/{NotificationSettings-DK2WXjhg.js → NotificationSettings-0H6xdiY3.js} +1 -1
  49. package/src/assets/web-panel/assets/{OrderTableRenderer-D1IAOQoo.js → OrderTableRenderer-CKP4AFWy.js} +1 -1
  50. package/src/assets/web-panel/assets/{Organization-9fqnPvTw.js → Organization-B8sxCV_h.js} +4 -4
  51. package/src/assets/web-panel/assets/{Overflow-n5xHkhow.js → Overflow-CW9JBBhQ.js} +1 -1
  52. package/src/assets/web-panel/assets/{P2P-B5AUWwH-.js → P2P-bdSQV7oZ.js} +2 -2
  53. package/src/assets/web-panel/assets/{PdhVaultBrowser-CoUDV6dy.js → PdhVaultBrowser-DQad42nW.js} +4 -4
  54. package/src/assets/web-panel/assets/{Permissions-DPRmQf_H.js → Permissions-CueexxTR.js} +4 -4
  55. package/src/assets/web-panel/assets/{PersonalDataHub-B37bsbEN.js → PersonalDataHub-fqxflhR8.js} +6 -6
  56. package/src/assets/web-panel/assets/{Pipeline-CCAuPqu9.js → Pipeline-KFwwnIMC.js} +1 -1
  57. package/src/assets/web-panel/assets/{Privacy-B81msZdv.js → Privacy-ClnD9buY.js} +1 -1
  58. package/src/assets/web-panel/assets/{ProjectInit-B-Py2NbF.js → ProjectInit-CZEz6sm3.js} +2 -2
  59. package/src/assets/web-panel/assets/{ProjectSettings-CYsneiRB.js → ProjectSettings-B6NzpmOI.js} +2 -2
  60. package/src/assets/web-panel/assets/{Projects-Y975Mh6R.js → Projects-DYyuwPCs.js} +1 -1
  61. package/src/assets/web-panel/assets/Providers-C3tz_jdh.js +1 -0
  62. package/src/assets/web-panel/assets/{QrScannerModal-Bj_6gJ0l.js → QrScannerModal-BzuOBbqB.js} +1 -1
  63. package/src/assets/web-panel/assets/{QuickAsk-BgpBCMPE.js → QuickAsk-CmNO9qSj.js} +1 -1
  64. package/src/assets/web-panel/assets/{Recommend-DhUW3YUA.js → Recommend-VJNj3_NF.js} +1 -1
  65. package/src/assets/web-panel/assets/{RemoteSession-DdeyfbkA.js → RemoteSession-CrbdbSBF.js} +2 -2
  66. package/src/assets/web-panel/assets/{Reputation-fxW421xd.js → Reputation-DTyMd45j.js} +1 -1
  67. package/src/assets/web-panel/assets/{Row-De73w1og.js → Row-DHrQ-Icz.js} +1 -1
  68. package/src/assets/web-panel/assets/{RssFeed-3US_Q_lj.js → RssFeed-DqWxY9c5.js} +2 -2
  69. package/src/assets/web-panel/assets/{Search-DbZ_DU74.js → Search-ctF1Ks3J.js} +1 -1
  70. package/src/assets/web-panel/assets/{Security-DoIU1vMF.js → Security-BrQz8uo5.js} +4 -4
  71. package/src/assets/web-panel/assets/{Services-DB_RGqpc.js → Services-D4scVFar.js} +2 -2
  72. package/src/assets/web-panel/assets/{Skeleton-DnzLaayC.js → Skeleton-oUScaOjv.js} +1 -1
  73. package/src/assets/web-panel/assets/{Skills-VzVXHENF.js → Skills-CL-y0eLt.js} +1 -1
  74. package/src/assets/web-panel/assets/{Sla-DMfvjCAI.js → Sla-DQExhwXj.js} +1 -1
  75. package/src/assets/web-panel/assets/{SpeechSettings-DQQOYWbF.js → SpeechSettings-iNp_I7kN.js} +1 -1
  76. package/src/assets/web-panel/assets/{SyncSettings-Bmx3FG1P.js → SyncSettings-CTDlMo0A.js} +2 -2
  77. package/src/assets/web-panel/assets/{Tasks-DOQyfb-2.js → Tasks-4kb8l3sc.js} +1 -1
  78. package/src/assets/web-panel/assets/{Templates-D6hTRT7c.js → Templates-BXcL9oXW.js} +1 -1
  79. package/src/assets/web-panel/assets/{Tenant-CTPQZAzs.js → Tenant-DLE8eS0D.js} +1 -1
  80. package/src/assets/web-panel/assets/{Terminal-B7vII3oH.js → Terminal-DGpUZPPK.js} +2 -2
  81. package/src/assets/web-panel/assets/{TimelineRenderer-BkiFJeXV.js → TimelineRenderer-C1pWXGPC.js} +1 -1
  82. package/src/assets/web-panel/assets/{Tokens-BPPdQMNo.js → Tokens-BYfbfqhZ.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trigger-BxG8JAEI.js → Trigger-K9YfLOCw.js} +1 -1
  84. package/src/assets/web-panel/assets/{Trust-DHD7nMpM.js → Trust-Ctkq77Hy.js} +1 -1
  85. package/src/assets/web-panel/assets/{UkeySign-B4BJpU47.js → UkeySign-C6_CSe9J.js} +1 -1
  86. package/src/assets/web-panel/assets/{VideoEditing-CRiGpFx2.js → VideoEditing-Be3rpOWs.js} +1 -1
  87. package/src/assets/web-panel/assets/{Wallet-BSDSNiem.js → Wallet-D0UIplxk.js} +4 -4
  88. package/src/assets/web-panel/assets/{WebAuthn-DLTUh5Zv.js → WebAuthn-CF9bM9I3.js} +5 -5
  89. package/src/assets/web-panel/assets/{WorkflowEditor-tW2MwjhD.js → WorkflowEditor-D0H1XVPY.js} +1 -1
  90. package/src/assets/web-panel/assets/{chat-SgsLQ3FC.js → chat-Cgzzbe0o.js} +1 -1
  91. package/src/assets/web-panel/assets/{colors-BHjjLIKO.js → colors-BwNxEVS0.js} +1 -1
  92. package/src/assets/web-panel/assets/{compact-item-VceErdKq.js → compact-item-D60Z4Hhs.js} +1 -1
  93. package/src/assets/web-panel/assets/{createContext-BZ332MYl.js → createContext-MFk58XB0.js} +1 -1
  94. package/src/assets/web-panel/assets/devWarning-BJiw369i.js +1 -0
  95. package/src/assets/web-panel/assets/{hasIn-CBTjJOjn.js → hasIn-CRj2SvGO.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-DaCCy6lq.js → index--s45_1cb.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-CMVvxIS8.js → index--tquQ2ZG.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-BhXZEUAc.js → index-1T1rhokn.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-BQSmZGgs.js → index-B-M82_rx.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BeiqkxJG.js → index-BE_el1Zs.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-29wVzi-k.js → index-BhRkR7IO.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-iYQNU9za.js → index-Bo0y96-K.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-BRWhLbWF.js → index-CJgu5JUK.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-BPXFLYdW.js → index-CLWwdebO.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-Cr4rjlW5.js → index-CNAK5QG_.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-CaIS_TNX.js → index-CQjJjPRD.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-1n7xjKDU.js → index-CTDbAfae.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BETEjuNr.js → index-CVI_9sRK.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-By5jBtkV.js → index-CY14oMQK.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DgPvuIn2.js → index-CbiTJkZf.js} +1 -1
  111. package/src/assets/web-panel/assets/index-CiMouosn.js +1 -0
  112. package/src/assets/web-panel/assets/{index-CawpdGTW.js → index-CqWpxMqx.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-_PHrIO4O.js → index-CvB8lYUR.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-BOtvPdHt.js → index-D51XwtCY.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-CrUgDQkf.js → index-D6HvK_1O.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-BY1wexYG.js → index-D9jVIwLJ.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-C_B9rFQE.js → index-DA4tE98u.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-B5KAGxNo.js → index-DBwOtXw0.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-BC1xuQgX.js → index-DKdNzTIb.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-B2u26AS3.js → index-DRySdmvN.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DePOeMDM.js → index-DWzyTev7.js} +1 -1
  122. package/src/assets/web-panel/assets/index-DXcKlwy7.js +1 -0
  123. package/src/assets/web-panel/assets/{index-BTlc-Bve.js → index-DYjs7jAP.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-CLc19alH.js → index-DbROzMBp.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-fU7wNHDB.js → index-DhFtk5PR.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-D_SePDWj.js → index-Di2AbMLg.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-vIXOpcjD.js → index-DvmocQsM.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BcUenmCX.js → index-Dw-o0zEF.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-DUkVN794.js → index-DysPoP38.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-CtjLcCNU.js → index-LX9pJxm_.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-B40dQn0G.js → index-OI_fXWx0.js} +3 -3
  132. package/src/assets/web-panel/assets/{index-FJ3zC9LK.js → index-eDTOX8Xj.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-DdefYBSw.js → index-gWKDsTzF.js} +1 -1
  134. package/src/assets/web-panel/assets/{index-CpumS98o.js → index-q_GDUVAA.js} +1 -1
  135. package/src/assets/web-panel/assets/{initDefaultProps-D1oZNz0D.js → initDefaultProps-C61kZqEd.js} +1 -1
  136. package/src/assets/web-panel/assets/{motion-C8k6doMO.js → motion-DZnT5U1L.js} +1 -1
  137. package/src/assets/web-panel/assets/{move-DJ4qSbYt.js → move-L8wzwbel.js} +1 -1
  138. package/src/assets/web-panel/assets/{mtc-parser-CdpJD85j.js → mtc-parser-DLdYSejc.js} +1 -1
  139. package/src/assets/web-panel/assets/{omit-B0V4slKA.js → omit-D53cbS59.js} +1 -1
  140. package/src/assets/web-panel/assets/{pickAttrs-Di5NiW1F.js → pickAttrs-Bbc5HPtu.js} +1 -1
  141. package/src/assets/web-panel/assets/{placementArrow-B5OMHgEM.js → placementArrow-BkvmO860.js} +1 -1
  142. package/src/assets/web-panel/assets/{responsiveObserve-C6U9IWql.js → responsiveObserve-u2y1ryS1.js} +1 -1
  143. package/src/assets/web-panel/assets/{slide-BMyTH3rN.js → slide-BBqj7svU.js} +1 -1
  144. package/src/assets/web-panel/assets/{statusUtils-C7d6BG7Z.js → statusUtils-GN0mqu2d.js} +1 -1
  145. package/src/assets/web-panel/assets/{styleChecker-CR50QnQy.js → styleChecker-BvwPKoBT.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFlexGapSupport-DBEeoMb8.js → useFlexGapSupport-CCt8tnfY.js} +1 -1
  147. package/src/assets/web-panel/assets/{useFs-3r099cjv.js → useFs-DXAxl2SV.js} +1 -1
  148. package/src/assets/web-panel/assets/{usePersonalDataHub-C5IYNdTK.js → usePersonalDataHub-DihlR2dj.js} +1 -1
  149. package/src/assets/web-panel/assets/{vnode-DmEOLRHf.js → vnode-D7BOX2gi.js} +1 -1
  150. package/src/assets/web-panel/assets/{zoom-DnpUiVCB.js → zoom-CVbO-nL7.js} +1 -1
  151. package/src/assets/web-panel/index.html +1 -1
  152. package/src/command-help-index.json +4 -4
  153. package/src/commands/agent.js +3 -3
  154. package/src/commands/background-session.js +145 -0
  155. package/src/commands/mcp.js +244 -7
  156. package/src/commands/serve.js +1 -1
  157. package/src/commands/session-show.js +129 -0
  158. package/src/commands/session.js +97 -145
  159. package/src/commands/skill.js +87 -50
  160. package/src/data/changelog.json +40 -1
  161. package/src/gateways/ws/session-protocol.js +14 -4
  162. package/src/gateways/ws/ws-agent-handler.js +39 -1
  163. package/src/harness/jsonl-session-store.js +1547 -127
  164. package/src/harness/mcp-client.js +2690 -409
  165. package/src/harness/session-index.js +80 -80
  166. package/src/harness/session-list-index.js +210 -7
  167. package/src/lazy-dispatch.js +76 -8
  168. package/src/lib/abort-utils.js +68 -0
  169. package/src/lib/agent-core.js +1 -59
  170. package/src/lib/background-interaction-journal.js +2 -2
  171. package/src/lib/chat-core.js +24 -6
  172. package/src/lib/command-lifecycle-report.js +516 -0
  173. package/src/lib/cowork-mcp-tools.js +47 -2
  174. package/src/lib/cowork-task-runner.js +11 -3
  175. package/src/lib/doctor-checkup.js +9 -7
  176. package/src/lib/learning/skill-improver.js +21 -2
  177. package/src/lib/mcp-call-ledger-store.js +116 -3
  178. package/src/lib/mcp-call-ledger.js +59 -14
  179. package/src/lib/mcp-client.js +1 -0
  180. package/src/lib/mcp-stdio-environment.js +48 -0
  181. package/src/lib/mcp-stdio-executable-identity.js +1133 -0
  182. package/src/lib/mcp-stdio-execution-authority.js +363 -0
  183. package/src/lib/mcp-stdio-package-materialization.js +1424 -0
  184. package/src/lib/mcp-tool-metadata.js +436 -0
  185. package/src/lib/mcp-tool-result.js +367 -0
  186. package/src/lib/observability/index.js +71 -0
  187. package/src/lib/packer/native-update-state.js +979 -1
  188. package/src/lib/packer/pack-update-applier.js +1050 -125
  189. package/src/lib/packer/pkg-config-generator.js +1 -0
  190. package/src/lib/paths.js +53 -0
  191. package/src/lib/pr-link-ledger.js +8 -27
  192. package/src/lib/pr-link-store.js +26 -0
  193. package/src/lib/process-execution-broker/index.js +192 -35
  194. package/src/lib/process-execution-broker/platform-sandbox.js +291 -24
  195. package/src/lib/process-tree-termination.js +253 -0
  196. package/src/lib/recent-session.js +17 -17
  197. package/src/lib/secure-file-identity.js +47 -0
  198. package/src/lib/secure-fs.js +21 -2
  199. package/src/lib/session-anti-rollback-anchor.js +766 -0
  200. package/src/lib/session-host-lease.js +492 -0
  201. package/src/lib/session-host-snapshot.js +9 -5
  202. package/src/lib/session-persistence-failure.js +109 -0
  203. package/src/lib/session-picker.js +26 -3
  204. package/src/lib/session-projection.js +5 -1
  205. package/src/lib/session-search.js +25 -11
  206. package/src/lib/session-tail.js +31 -1
  207. package/src/lib/side-effect-ledger-store.js +2 -2
  208. package/src/lib/skill-budget.js +222 -0
  209. package/src/lib/skill-execution-authority.js +385 -0
  210. package/src/lib/skill-execution-identity.js +39 -2
  211. package/src/lib/skill-loader.js +322 -86
  212. package/src/lib/skill-mcp.js +43 -1
  213. package/src/lib/turn-binding-store.js +2 -2
  214. package/src/repl/agent-repl.js +483 -77
  215. package/src/repl/bg-dashboard.js +81 -28
  216. package/src/repl/chat-repl.js +77 -12
  217. package/src/runtime/agent-core.js +428 -113
  218. package/src/runtime/agent-runtime.js +26 -3
  219. package/src/runtime/cleanup-deadline.js +143 -0
  220. package/src/runtime/headless-runner.js +494 -149
  221. package/src/runtime/headless-stream.js +504 -75
  222. package/src/runtime/mcp-config.js +157 -10
  223. package/src/runtime/mcp-tool-search.js +0 -0
  224. package/src/runtime/output-backpressure.js +419 -0
  225. package/src/runtime/pipe-safety.js +56 -21
  226. package/src/assets/web-panel/assets/ChatBubbleRenderer-BUUyJ5QO.js +0 -1
  227. package/src/assets/web-panel/assets/MarkdownRenderer-1mNozZ8v.js +0 -1
  228. package/src/assets/web-panel/assets/MobileProjects-DqnoFPgR.js +0 -1
  229. package/src/assets/web-panel/assets/Providers-uO-10aP1.js +0 -1
  230. package/src/assets/web-panel/assets/devWarning-DosMaL-k.js +0 -1
  231. package/src/assets/web-panel/assets/index-0O5ePnUq.js +0 -1
  232. package/src/assets/web-panel/assets/index-ChAzPigF.js +0 -1
  233. package/src/lib/skill-process-broker.js +0 -85
@@ -12,16 +12,32 @@ import { executionBroker } from "../lib/process-execution-broker/index.js";
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import { EventEmitter } from "events";
14
14
  import { pathToFileURL } from "url";
15
+ import { isProxy } from "node:util/types";
15
16
  import WebSocket from "ws";
16
17
  import { safeJsonParse } from "../lib/safe-json.js";
17
18
  import { EventRuntimeProducer } from "../lib/event-runtime-producer.js";
18
19
  import { currentHostHooksV2WorkspaceBinding } from "../lib/hooks-v2-workspace-context.js";
19
20
  import { resolvePluginWorkspaceAuthority } from "../lib/plugin-runtime/sandbox-policy.js";
21
+ import {
22
+ admitMcpToolList,
23
+ isMcpToolMetadataError,
24
+ } from "../lib/mcp-tool-metadata.js";
25
+ import { admitMcpToolResult } from "../lib/mcp-tool-result.js";
26
+ import { terminateOwnedProcessTree } from "../lib/process-tree-termination.js";
20
27
  import {
21
28
  mergeMcpHeaders,
22
29
  resolveMcpHeadersHelperContext,
23
30
  runMcpHeadersHelper,
24
31
  } from "../lib/mcp-headers-helper.js";
32
+ import {
33
+ consumeMcpStdioExecutionAuthority,
34
+ materializeApprovedMcpStdioInvocation,
35
+ renewMcpStdioExecutionAuthority,
36
+ } from "../lib/mcp-stdio-execution-authority.js";
37
+ import {
38
+ MCP_STDIO_CAPSULE_CODE_SNAPSHOT_BOUNDARY,
39
+ prepareMcpStdioExecutableIdentity,
40
+ } from "../lib/mcp-stdio-executable-identity.js";
25
41
 
26
42
  /**
27
43
  * Injectable dependencies — overridable from tests.
@@ -33,10 +49,44 @@ export const _deps = {
33
49
  WebSocket,
34
50
  runMcpHeadersHelper,
35
51
  resolveMcpHeadersHelperContext,
52
+ terminateOwnedProcessTree,
53
+ consumeMcpStdioExecutionAuthority,
54
+ materializeApprovedMcpStdioInvocation,
55
+ renewMcpStdioExecutionAuthority,
56
+ prepareMcpStdioExecutableIdentity,
36
57
  // Backoff sleep seam (tests override with a no-op so retries don't wait).
37
58
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
38
59
  };
39
60
 
61
+ function mcpStdioCleanupError(name, result) {
62
+ const error = new Error(
63
+ `MCP server "${name}" process tree did not terminate before the cleanup deadline`,
64
+ );
65
+ error.code = "CC_MCP_STDIO_PROCESS_TREE_CLEANUP_TIMEOUT";
66
+ error.cleanup = result;
67
+ return error;
68
+ }
69
+
70
+ function retireStdioEntryProcess(entry, options = {}) {
71
+ if (!entry?.process) return Promise.resolve(null);
72
+ if (entry._stdioRetirementPromise) return entry._stdioRetirementPromise;
73
+ const retirement = Promise.resolve()
74
+ .then(() =>
75
+ _deps.terminateOwnedProcessTree(entry.process, {
76
+ alreadyClosed: options.alreadyClosed === true,
77
+ cleanupTimeoutMs: entry.config?.processTreeCleanupTimeoutMs,
78
+ graceMs: entry.config?.processTreeGraceMs,
79
+ treeMode: entry._stdioTreeMode,
80
+ }),
81
+ )
82
+ .then((result) => {
83
+ entry._stdioTreeCleanup = result;
84
+ return result;
85
+ });
86
+ entry._stdioRetirementPromise = retirement;
87
+ return retirement;
88
+ }
89
+
40
90
  /**
41
91
  * MCP Server connection states.
42
92
  */
@@ -73,9 +123,40 @@ export function normalizeMcpConfigScope(scope = "user") {
73
123
  * review) are exempt; override per server with `config.requestTimeoutMs`.
74
124
  */
75
125
  const HTTP_REQUEST_TIMEOUT_MS = 30000;
126
+ // Responses to one-way HTTP messages are never semantically consumed, but the
127
+ // transport still needs a host-owned deadline so a peer cannot retain sockets,
128
+ // timers, and client state forever by withholding response headers. A server's
129
+ // requestTimeoutMs may tighten this deadline; zero/invalid/oversized values do
130
+ // not disable or raise it.
131
+ const MCP_HTTP_DISCARD_TIMEOUT_MS = 30000;
132
+ // Once a successful finite response has supplied its headers, its body must
133
+ // still finish inside a host-owned absolute deadline. `longRunning` may exempt
134
+ // the peer's computation/header wait, but it must not let a byte-at-a-time body
135
+ // retain the request for years. Server config can only tighten this ceiling.
136
+ const MCP_HTTP_RESPONSE_READ_TIMEOUT_MS = 30000;
137
+ // A background SSE connection may stay open indefinitely, but one individual
138
+ // event must not retain its parser state forever by sending a byte at a time.
139
+ // Positive requestTimeoutMs values may tighten this host ceiling; zero,
140
+ // longRunning, invalid, and oversized values cannot disable or raise it.
141
+ const MCP_SSE_EVENT_TIMEOUT_MS = 30000;
76
142
  const WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
77
143
  const WEBSOCKET_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
78
144
 
145
+ function webSocketPayloadLimit(configuredLimit) {
146
+ if (Number.isFinite(configuredLimit)) {
147
+ return Math.min(
148
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
149
+ Math.max(1024, Math.floor(configuredLimit)),
150
+ );
151
+ }
152
+ return WEBSOCKET_MAX_PAYLOAD_BYTES;
153
+ }
154
+
155
+ // Host-owned ceiling for every finite HTTP MCP response body. Server config
156
+ // may tighten this through maxBufferChars for backwards compatibility, but it
157
+ // cannot raise or disable this byte limit.
158
+ const MCP_HTTP_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
159
+
79
160
  /**
80
161
  * Default per-call timeout for stdio MCP requests — the same 30s default as the
81
162
  * HTTP path. The stdio request path now honours the SAME `config.longRunning`
@@ -87,6 +168,26 @@ const WEBSOCKET_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
87
168
  const STDIO_REQUEST_TIMEOUT_MS = 30000;
88
169
  const MCP_PROTOCOL_VERSION = "2025-11-25";
89
170
  const URL_ELICITATION_REQUIRED = -32042;
171
+ const MCP_RPC_ERROR_CODE = "CC_MCP_RPC_ERROR";
172
+ const mcpRpcControlData = new WeakMap();
173
+ const mcpRpcErrors = new WeakSet();
174
+ const MCP_RPC_URL_ELICITATION_MAX_COUNT = 16;
175
+ const MCP_RPC_URL_ELICITATION_MODE_MAX_BYTES = 16;
176
+ const MCP_RPC_URL_ELICITATION_ID_MAX_BYTES = 256;
177
+ const MCP_RPC_URL_ELICITATION_URL_MAX_BYTES = 8192;
178
+ const MCP_RPC_URL_ELICITATION_MESSAGE_MAX_BYTES = 4096;
179
+ const MCP_URL_ELICITATION_HISTORY_MAX_COUNT = 1000;
180
+
181
+ export function isMcpRpcError(value) {
182
+ if (!value || (typeof value !== "object" && typeof value !== "function")) {
183
+ return false;
184
+ }
185
+ try {
186
+ return !isProxy(value) && mcpRpcErrors.has(value);
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
90
191
 
91
192
  function supportsUrlElicitationVersion(version) {
92
193
  const normalized = String(version || "");
@@ -123,10 +224,217 @@ export function inferTransport(config) {
123
224
  return "stdio";
124
225
  }
125
226
 
227
+ function safeRpcProperty(value, property) {
228
+ return rpcWireDataProperty(value, property);
229
+ }
230
+
231
+ function rpcWireField(value, property) {
232
+ if ((typeof value !== "object" && typeof value !== "function") || !value) {
233
+ return { valid: false, present: false, value: undefined };
234
+ }
235
+ try {
236
+ if (isProxy(value)) {
237
+ return { valid: false, present: false, value: undefined };
238
+ }
239
+ const descriptor = Object.getOwnPropertyDescriptor(value, property);
240
+ if (!descriptor) {
241
+ return { valid: true, present: false, value: undefined };
242
+ }
243
+ if (!Object.hasOwn(descriptor, "value")) {
244
+ return { valid: false, present: true, value: undefined };
245
+ }
246
+ return { valid: true, present: true, value: descriptor.value };
247
+ } catch {
248
+ return { valid: false, present: false, value: undefined };
249
+ }
250
+ }
251
+
252
+ function rpcWireDataProperty(value, property) {
253
+ const field = rpcWireField(value, property);
254
+ return field.valid && field.present ? field.value : undefined;
255
+ }
256
+
257
+ function snapshotRpcMessage(message) {
258
+ try {
259
+ if (
260
+ !message ||
261
+ typeof message !== "object" ||
262
+ Array.isArray(message) ||
263
+ isProxy(message)
264
+ ) {
265
+ return null;
266
+ }
267
+ } catch {
268
+ return null;
269
+ }
270
+ const snapshot = {};
271
+ for (const fieldName of [
272
+ "jsonrpc",
273
+ "id",
274
+ "method",
275
+ "params",
276
+ "result",
277
+ "error",
278
+ ]) {
279
+ const field = rpcWireField(message, fieldName);
280
+ if (!field.valid) return null;
281
+ snapshot[fieldName] = field.value;
282
+ snapshot[`has${fieldName[0].toUpperCase()}${fieldName.slice(1)}`] =
283
+ field.present;
284
+ }
285
+ return Object.freeze(snapshot);
286
+ }
287
+
288
+ function boundedRpcControlString(value, maxBytes) {
289
+ if (
290
+ typeof value !== "string" ||
291
+ Buffer.byteLength(value, "utf8") > maxBytes ||
292
+ /\p{Cc}/u.test(value)
293
+ ) {
294
+ return null;
295
+ }
296
+ return value;
297
+ }
298
+
299
+ function snapshotRpcUrlElicitations(payload) {
300
+ const data = rpcWireDataProperty(payload, "data");
301
+ const requested = rpcWireDataProperty(data, "elicitations");
302
+ try {
303
+ if (!Array.isArray(requested) || isProxy(requested)) return null;
304
+ const length = rpcWireDataProperty(requested, "length");
305
+ if (!Number.isSafeInteger(length) || length < 0) return null;
306
+ const snapshots = [];
307
+ const retained = Math.min(length, MCP_RPC_URL_ELICITATION_MAX_COUNT + 1);
308
+ for (let index = 0; index < retained; index += 1) {
309
+ const item = rpcWireDataProperty(requested, String(index));
310
+ const mode = boundedRpcControlString(
311
+ rpcWireDataProperty(item, "mode"),
312
+ MCP_RPC_URL_ELICITATION_MODE_MAX_BYTES,
313
+ );
314
+ const elicitationId = boundedRpcControlString(
315
+ rpcWireDataProperty(item, "elicitationId"),
316
+ MCP_RPC_URL_ELICITATION_ID_MAX_BYTES,
317
+ );
318
+ const url = boundedRpcControlString(
319
+ rpcWireDataProperty(item, "url"),
320
+ MCP_RPC_URL_ELICITATION_URL_MAX_BYTES,
321
+ );
322
+ const message = boundedRpcControlString(
323
+ rpcWireDataProperty(item, "message"),
324
+ MCP_RPC_URL_ELICITATION_MESSAGE_MAX_BYTES,
325
+ );
326
+ if (
327
+ mode === null ||
328
+ elicitationId === null ||
329
+ url === null ||
330
+ message === null
331
+ ) {
332
+ return null;
333
+ }
334
+ snapshots.push(
335
+ Object.freeze({
336
+ mode,
337
+ elicitationId,
338
+ url,
339
+ message,
340
+ }),
341
+ );
342
+ }
343
+ return Object.freeze(snapshots);
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+
349
+ function mcpRpcProtocolError(code, message) {
350
+ const error = new Error(message);
351
+ error.name = "McpRpcProtocolError";
352
+ error.code = code;
353
+ return error;
354
+ }
355
+
356
+ function mcpRpcInvalidError() {
357
+ return mcpRpcProtocolError(
358
+ "CC_MCP_RPC_ERROR_INVALID",
359
+ "MCP server returned an invalid JSON-RPC error object",
360
+ );
361
+ }
362
+
363
+ function mcpRpcInvalidResponseError() {
364
+ return mcpRpcProtocolError(
365
+ "CC_MCP_RPC_RESPONSE_INVALID",
366
+ "MCP server returned an invalid JSON-RPC response",
367
+ );
368
+ }
369
+
370
+ function mcpRpcInvalidMessageError() {
371
+ return mcpRpcProtocolError(
372
+ "CC_MCP_RPC_MESSAGE_INVALID",
373
+ "MCP server sent an invalid JSON-RPC message",
374
+ );
375
+ }
376
+
377
+ function mcpRpcErrorLabel(code) {
378
+ switch (code) {
379
+ case -32700:
380
+ return "Parse error";
381
+ case -32600:
382
+ return "Invalid Request";
383
+ case -32601:
384
+ return "Method not found";
385
+ case -32602:
386
+ return "Invalid params";
387
+ case -32603:
388
+ return "Internal error";
389
+ case URL_ELICITATION_REQUIRED:
390
+ return "URL elicitation required";
391
+ default:
392
+ return null;
393
+ }
394
+ }
395
+
126
396
  function mcpRpcError(payload = {}) {
127
- const error = new Error(payload.message || "Unknown MCP error");
128
- if (payload.code !== undefined) error.code = payload.code;
129
- if (payload.data !== undefined) error.data = payload.data;
397
+ const codeField = rpcWireField(payload, "code");
398
+ const messageField = rpcWireField(payload, "message");
399
+ if (
400
+ !codeField.valid ||
401
+ !codeField.present ||
402
+ !Number.isSafeInteger(codeField.value) ||
403
+ !messageField.valid ||
404
+ !messageField.present ||
405
+ typeof messageField.value !== "string"
406
+ ) {
407
+ return mcpRpcInvalidError();
408
+ }
409
+
410
+ const rpcCode = codeField.value;
411
+ const label = mcpRpcErrorLabel(rpcCode);
412
+ const detail = ` (code ${rpcCode}${label ? `: ${label}` : ""})`;
413
+ // JSON-RPC message/data are peer-controlled and routinely flow into stderr,
414
+ // tool results, model context and session persistence. Keep diagnostics
415
+ // host-generated. The one data-bearing control flow we support (-32042) is
416
+ // retained out-of-band in a WeakMap and never attached to the Error object.
417
+ const error = new Error(`MCP server returned a JSON-RPC error${detail}`);
418
+ error.name = "McpRpcError";
419
+ error.mcpErrorCode = MCP_RPC_ERROR_CODE;
420
+ error.rpcCode = rpcCode;
421
+ error.code = rpcCode;
422
+ mcpRpcErrors.add(error);
423
+
424
+ if (rpcCode === URL_ELICITATION_REQUIRED) {
425
+ const elicitations = snapshotRpcUrlElicitations(payload);
426
+ if (elicitations) {
427
+ // Retain only a frozen allowlist snapshot, capped one item beyond the
428
+ // accepted maximum so an oversized batch is rejected without keeping an
429
+ // attacker-sized object graph alive.
430
+ mcpRpcControlData.set(
431
+ error,
432
+ Object.freeze({
433
+ elicitations,
434
+ }),
435
+ );
436
+ }
437
+ }
130
438
  return error;
131
439
  }
132
440
 
@@ -224,9 +532,200 @@ function mcpTransportError(code, message, details = {}) {
224
532
  error.url = details.url ? redactMcpUrl(details.url) : null;
225
533
  if (details.status != null) error.status = details.status;
226
534
  if (details.closeCode != null) error.closeCode = details.closeCode;
535
+ if (details.limitBytes != null) error.limitBytes = details.limitBytes;
536
+ if (details.limitFrames != null) error.limitFrames = details.limitFrames;
537
+ if (details.limitMessages != null) {
538
+ error.limitMessages = details.limitMessages;
539
+ }
540
+ if (details.limitDepth != null) error.limitDepth = details.limitDepth;
541
+ if (details.limitNodes != null) error.limitNodes = details.limitNodes;
542
+ if (details.windowMs != null) error.windowMs = details.windowMs;
543
+ if (details.dispatched != null) {
544
+ error.dispatched = details.dispatched === true;
545
+ }
546
+ if (details.outcomeUnknown != null) {
547
+ error.outcomeUnknown = details.outcomeUnknown === true;
548
+ }
549
+ return error;
550
+ }
551
+
552
+ function mcpWebSocketPayloadTooLargeError(
553
+ serverName,
554
+ { transport, url, limitBytes, closeCode = 1009 },
555
+ ) {
556
+ return mcpTransportError(
557
+ "CC_MCP_WS_PAYLOAD_TOO_LARGE",
558
+ `MCP WebSocket connection for server "${serverName}" rejected a payload over the ${limitBytes}-byte host cap`,
559
+ { transport, url, closeCode, limitBytes },
560
+ );
561
+ }
562
+
563
+ function mcpServerDisconnectingError(serverName, entry, details = {}) {
564
+ return mcpTransportError(
565
+ "CC_MCP_SERVER_DISCONNECTING",
566
+ `MCP server "${serverName}" is disconnecting`,
567
+ {
568
+ transport: entry?.transportKind,
569
+ url: entry?.config?.url,
570
+ dispatched: details.dispatched,
571
+ outcomeUnknown: details.dispatched,
572
+ },
573
+ );
574
+ }
575
+
576
+ function mcpRequestAbortedError(serverName, entry, dispatched) {
577
+ return mcpTransportError(
578
+ "CC_MCP_REQUEST_ABORTED",
579
+ `MCP request for server "${serverName}" was cancelled by its caller`,
580
+ {
581
+ transport: entry?.transportKind,
582
+ url: entry?.config?.url,
583
+ dispatched,
584
+ outcomeUnknown: dispatched,
585
+ },
586
+ );
587
+ }
588
+
589
+ function mcpInboundBudgetError(serverName, entry, budget) {
590
+ const dispatched = entry?._pending instanceof Map && entry._pending.size > 0;
591
+ if (budget === "messages") {
592
+ const limitMessages = inboundMessageRateLimit(
593
+ entry?.config?.maxInboundMessagesPerSecond,
594
+ );
595
+ return mcpTransportError(
596
+ "CC_MCP_INBOUND_RATE_EXCEEDED",
597
+ `MCP server "${serverName}" exceeded the ${limitMessages}-message/${MCP_INBOUND_MESSAGE_RATE_WINDOW_MS}ms host inbound rate budget`,
598
+ {
599
+ transport: entry?.transportKind,
600
+ url: entry?.config?.url,
601
+ limitMessages,
602
+ windowMs: MCP_INBOUND_MESSAGE_RATE_WINDOW_MS,
603
+ dispatched,
604
+ outcomeUnknown: dispatched,
605
+ },
606
+ );
607
+ }
608
+ const limitBytes = inboundTrafficByteLimit(
609
+ entry?.config?.maxInboundBytesPerMinute,
610
+ );
611
+ return mcpTransportError(
612
+ "CC_MCP_INBOUND_TRAFFIC_EXCEEDED",
613
+ `MCP server "${serverName}" exceeded the ${limitBytes}-byte/${MCP_INBOUND_TRAFFIC_WINDOW_MS}ms host inbound traffic budget`,
614
+ {
615
+ transport: entry?.transportKind,
616
+ url: entry?.config?.url,
617
+ limitBytes,
618
+ windowMs: MCP_INBOUND_TRAFFIC_WINDOW_MS,
619
+ dispatched,
620
+ outcomeUnknown: dispatched,
621
+ },
622
+ );
623
+ }
624
+
625
+ function mcpJsonBudgetError(serverName, entry, budget) {
626
+ const dispatched = Boolean(
627
+ (entry?._pending instanceof Map && entry._pending.size > 0) ||
628
+ (entry?._httpRequestControllers instanceof Set &&
629
+ entry._httpRequestControllers.size > 0),
630
+ );
631
+ if (budget === "depth") {
632
+ const limitDepth = mcpJsonDepthLimit(entry?.config?.maxJsonDepth);
633
+ return mcpTransportError(
634
+ "CC_MCP_JSON_DEPTH_EXCEEDED",
635
+ `MCP server "${serverName}" exceeded the ${limitDepth}-level host JSON depth budget`,
636
+ {
637
+ transport: entry?.transportKind,
638
+ url: entry?.config?.url || entry?.httpUrl,
639
+ limitDepth,
640
+ dispatched,
641
+ outcomeUnknown: dispatched,
642
+ },
643
+ );
644
+ }
645
+ const limitNodes = mcpJsonNodeLimit(entry?.config?.maxJsonNodes);
646
+ return mcpTransportError(
647
+ "CC_MCP_JSON_NODES_EXCEEDED",
648
+ `MCP server "${serverName}" exceeded the ${limitNodes}-node host JSON graph budget`,
649
+ {
650
+ transport: entry?.transportKind,
651
+ url: entry?.config?.url || entry?.httpUrl,
652
+ limitNodes,
653
+ dispatched,
654
+ outcomeUnknown: dispatched,
655
+ },
656
+ );
657
+ }
658
+
659
+ function mcpSseEventTimeoutError(timeoutMs) {
660
+ const error = new Error(
661
+ `MCP HTTP message stream event exceeded the ${timeoutMs}ms host deadline`,
662
+ );
663
+ error.code = "CC_MCP_SSE_EVENT_TIMEOUT";
664
+ error.timeoutMs = timeoutMs;
227
665
  return error;
228
666
  }
229
667
 
668
+ function mcpHttpResponseTimeoutError(
669
+ serverName,
670
+ entry,
671
+ method,
672
+ timeoutMs,
673
+ dispatched,
674
+ ) {
675
+ return mcpTransportError(
676
+ "CC_MCP_HTTP_RESPONSE_TIMEOUT",
677
+ `MCP HTTP response body for method "${method}" exceeded the ${timeoutMs}ms host deadline`,
678
+ {
679
+ transport: entry?.transportKind,
680
+ url: entry?.config?.url,
681
+ dispatched,
682
+ outcomeUnknown: dispatched,
683
+ },
684
+ );
685
+ }
686
+
687
+ function isWebSocketPayloadError(error) {
688
+ const code = safeRpcProperty(error, "code");
689
+ return (
690
+ code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ||
691
+ code === "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH"
692
+ );
693
+ }
694
+
695
+ function safeRpcErrorText(error) {
696
+ if (typeof error === "string") return error;
697
+ const message = safeRpcProperty(error, "message");
698
+ return typeof message === "string" ? message : "";
699
+ }
700
+
701
+ const MCP_NON_RETRYABLE_PROTOCOL_ERROR_CODES = new Set([
702
+ "CC_MCP_WS_PAYLOAD_TOO_LARGE",
703
+ "CC_MCP_WS_BINARY_MESSAGE",
704
+ "CC_MCP_WS_INVALID_MESSAGE",
705
+ "CC_MCP_SERVER_DISCONNECTING",
706
+ "CC_MCP_REQUEST_ABORTED",
707
+ "CC_MCP_HTTP_RESPONSE_STREAM_REQUIRED",
708
+ "CC_MCP_HTTP_RESPONSE_TIMEOUT",
709
+ "CC_MCP_HTTP_DISCARD_TIMEOUT",
710
+ "CC_MCP_SSE_EVENT_TIMEOUT",
711
+ "CC_MCP_INBOUND_RATE_EXCEEDED",
712
+ "CC_MCP_INBOUND_TRAFFIC_EXCEEDED",
713
+ "CC_MCP_JSON_DEPTH_EXCEEDED",
714
+ "CC_MCP_JSON_NODES_EXCEEDED",
715
+ "CC_MCP_STDIO_FRAME_TOO_LARGE",
716
+ "CC_MCP_STDIO_MALFORMED_BUDGET_EXCEEDED",
717
+ "CC_MCP_STDIO_STDERR_TOO_LARGE",
718
+ "CC_MCP_RPC_ERROR_INVALID",
719
+ "CC_MCP_RPC_RESPONSE_INVALID",
720
+ "CC_MCP_RPC_MESSAGE_INVALID",
721
+ ]);
722
+
723
+ function isMcpNonRetryableProtocolError(error) {
724
+ return MCP_NON_RETRYABLE_PROTOCOL_ERROR_CODES.has(
725
+ safeRpcProperty(error, "code"),
726
+ );
727
+ }
728
+
230
729
  /**
231
730
  * Heuristic: does this error look like the server went away (vs. the tool
232
731
  * itself failing)? Used to gate reconnect-and-retry for servers that have a
@@ -238,17 +737,22 @@ function mcpTransportError(code, message, details = {}) {
238
737
  * this client's own "server gone" states.
239
738
  */
240
739
  export function isLikelyConnectionError(err) {
241
- const msg = String((err && err.message) || err || "");
740
+ if (isMcpRpcError(err) || isMcpNonRetryableProtocolError(err)) {
741
+ return false;
742
+ }
743
+ const msg = safeRpcErrorText(err);
242
744
  return /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|socket hang up|network error|WebSocket|HTTP 40[134]\b|not connected|not found|not available/i.test(
243
745
  msg,
244
746
  );
245
747
  }
246
748
 
247
749
  export function isMcpAuthenticationError(err) {
750
+ if (isMcpRpcError(err)) return false;
751
+ const status = safeRpcProperty(err, "status");
248
752
  return (
249
- err?.status === 401 ||
250
- err?.status === 403 ||
251
- /\bHTTP\s*40[13]\b/i.test(String(err?.message || err || ""))
753
+ status === 401 ||
754
+ status === 403 ||
755
+ /\bHTTP\s*40[13]\b/i.test(safeRpcErrorText(err))
252
756
  );
253
757
  }
254
758
 
@@ -256,16 +760,338 @@ export function isMcpAuthenticationError(err) {
256
760
  const MCP_DISCOVERY_RETRIES = 2; // up to 3 attempts total
257
761
  const MCP_DISCOVERY_BACKOFF_MS = 250; // 250ms, 500ms
258
762
 
259
- /**
260
- * Hard cap on the unterminated stdout tail buffered per stdio server. JSON-RPC
261
- * frames are newline-delimited; a runaway or non-MCP process that streams
262
- * without ever emitting a newline would otherwise grow `entry._buffer` without
263
- * bound and exhaust memory. 16M chars is far above any legitimate single MCP
264
- * message (even a tool result with embedded base64), so crossing it signals a
265
- * misbehaving server. Override per server with `config.maxBufferChars` (0
266
- * disables the cap).
267
- */
268
- const MCP_MAX_BUFFER_CHARS = 16 * 1024 * 1024;
763
+ // Long-lived stdio, WebSocket, and background SSE transports may legitimately
764
+ // run forever, so lifetime totals would eventually kill every healthy peer.
765
+ // Token buckets instead bound any sustained inbound message/byte flow while
766
+ // retaining a finite burst allowance. Ordinary config may tighten these caps
767
+ // but cannot disable or raise them.
768
+ const MCP_INBOUND_MESSAGE_RATE_WINDOW_MS = 1000;
769
+ const MCP_INBOUND_MESSAGE_RATE_MAX = 128;
770
+ const MCP_INBOUND_TRAFFIC_WINDOW_MS = 60_000;
771
+ const MCP_INBOUND_TRAFFIC_MAX_BYTES = 64 * 1024 * 1024;
772
+
773
+ function inboundMessageRateLimit(configuredLimit) {
774
+ if (Number.isFinite(configuredLimit) && configuredLimit > 0) {
775
+ return Math.min(
776
+ MCP_INBOUND_MESSAGE_RATE_MAX,
777
+ Math.max(1, Math.floor(configuredLimit)),
778
+ );
779
+ }
780
+ return MCP_INBOUND_MESSAGE_RATE_MAX;
781
+ }
782
+
783
+ function inboundTrafficByteLimit(configuredLimit) {
784
+ if (Number.isFinite(configuredLimit) && configuredLimit > 0) {
785
+ return Math.min(
786
+ MCP_INBOUND_TRAFFIC_MAX_BYTES,
787
+ Math.max(1, Math.floor(configuredLimit)),
788
+ );
789
+ }
790
+ return MCP_INBOUND_TRAFFIC_MAX_BYTES;
791
+ }
792
+
793
+ function recordMcpInboundTraffic(serverName, entry, inboundBytes) {
794
+ const messageCapacity = inboundMessageRateLimit(
795
+ entry?.config?.maxInboundMessagesPerSecond,
796
+ );
797
+ const byteCapacity = inboundTrafficByteLimit(
798
+ entry?.config?.maxInboundBytesPerMinute,
799
+ );
800
+ const now = Date.now();
801
+ let budget = entry?._inboundTrafficBudget;
802
+ if (
803
+ !budget ||
804
+ budget.messageCapacity !== messageCapacity ||
805
+ budget.byteCapacity !== byteCapacity
806
+ ) {
807
+ budget = {
808
+ messageCapacity,
809
+ byteCapacity,
810
+ messageTokens: messageCapacity,
811
+ byteTokens: byteCapacity,
812
+ lastRefillMs: now,
813
+ };
814
+ entry._inboundTrafficBudget = budget;
815
+ }
816
+
817
+ const elapsedMs = Math.max(0, now - budget.lastRefillMs);
818
+ if (elapsedMs > 0) {
819
+ budget.messageTokens = Math.min(
820
+ messageCapacity,
821
+ budget.messageTokens +
822
+ (elapsedMs * messageCapacity) / MCP_INBOUND_MESSAGE_RATE_WINDOW_MS,
823
+ );
824
+ budget.byteTokens = Math.min(
825
+ byteCapacity,
826
+ budget.byteTokens +
827
+ (elapsedMs * byteCapacity) / MCP_INBOUND_TRAFFIC_WINDOW_MS,
828
+ );
829
+ budget.lastRefillMs = now;
830
+ }
831
+
832
+ const bytes =
833
+ Number.isFinite(inboundBytes) && inboundBytes > 0
834
+ ? Math.ceil(inboundBytes)
835
+ : 0;
836
+ if (budget.messageTokens < 1) {
837
+ return mcpInboundBudgetError(serverName, entry, "messages");
838
+ }
839
+ if (budget.byteTokens < bytes) {
840
+ return mcpInboundBudgetError(serverName, entry, "bytes");
841
+ }
842
+ budget.messageTokens -= 1;
843
+ budget.byteTokens -= bytes;
844
+ return null;
845
+ }
846
+
847
+ // A small JSON wire value can expand into a much larger object graph, and a
848
+ // deeply nested result can overflow recursive downstream consumers even while
849
+ // it remains below every transport byte cap. Scan the wire iteratively before
850
+ // JSON.parse, then inspect the resulting graph without invoking accessors or
851
+ // Proxy traps. These defaults match the strict durable-message JSON boundary;
852
+ // server config may tighten but never disable or raise them.
853
+ const MCP_JSON_MAX_DEPTH = 100;
854
+ const MCP_JSON_MAX_NODES = 100_000;
855
+
856
+ function mcpJsonDepthLimit(configuredLimit) {
857
+ if (Number.isFinite(configuredLimit) && configuredLimit > 0) {
858
+ return Math.min(
859
+ MCP_JSON_MAX_DEPTH,
860
+ Math.max(1, Math.floor(configuredLimit)),
861
+ );
862
+ }
863
+ return MCP_JSON_MAX_DEPTH;
864
+ }
865
+
866
+ function mcpJsonNodeLimit(configuredLimit) {
867
+ if (Number.isFinite(configuredLimit) && configuredLimit > 0) {
868
+ return Math.min(
869
+ MCP_JSON_MAX_NODES,
870
+ Math.max(1, Math.floor(configuredLimit)),
871
+ );
872
+ }
873
+ return MCP_JSON_MAX_NODES;
874
+ }
875
+
876
+ function inspectMcpJsonWireBudget(text, config = {}) {
877
+ const limitDepth = mcpJsonDepthLimit(config?.maxJsonDepth);
878
+ const limitNodes = mcpJsonNodeLimit(config?.maxJsonNodes);
879
+ const source = String(text || "");
880
+ let depth = 0;
881
+ let nodes = 0;
882
+ let inString = false;
883
+ let escaped = false;
884
+
885
+ const addNode = () => {
886
+ nodes += 1;
887
+ return nodes > limitNodes ? "nodes" : null;
888
+ };
889
+
890
+ for (let index = 0; index < source.length; index += 1) {
891
+ const char = source[index];
892
+ if (inString) {
893
+ if (escaped) {
894
+ escaped = false;
895
+ } else if (char === "\\") {
896
+ escaped = true;
897
+ } else if (char === '"') {
898
+ inString = false;
899
+ }
900
+ continue;
901
+ }
902
+
903
+ if (char === '"') {
904
+ const issue = addNode();
905
+ if (issue) return issue;
906
+ inString = true;
907
+ continue;
908
+ }
909
+ if (char === "{" || char === "[") {
910
+ const issue = addNode();
911
+ if (issue) return issue;
912
+ depth += 1;
913
+ if (depth > limitDepth) return "depth";
914
+ continue;
915
+ }
916
+ if (char === "}" || char === "]") {
917
+ depth = Math.max(0, depth - 1);
918
+ continue;
919
+ }
920
+ if (/\s/u.test(char) || char === "," || char === ":") continue;
921
+
922
+ const issue = addNode();
923
+ if (issue) return issue;
924
+ while (index + 1 < source.length && !/[\s,\]:}]/u.test(source[index + 1])) {
925
+ index += 1;
926
+ }
927
+ }
928
+ return null;
929
+ }
930
+
931
+ function inspectMcpJsonGraphBudget(value, config = {}) {
932
+ const limitDepth = mcpJsonDepthLimit(config?.maxJsonDepth);
933
+ const limitNodes = mcpJsonNodeLimit(config?.maxJsonNodes);
934
+ const stack = [{ value, parentDepth: 0 }];
935
+ const seen = new WeakSet();
936
+ let nodes = 0;
937
+ let invalid = false;
938
+
939
+ const consumeNodes = (count = 1) => {
940
+ nodes += count;
941
+ return nodes > limitNodes;
942
+ };
943
+
944
+ while (stack.length > 0) {
945
+ const current = stack.pop();
946
+ const candidate = current.value;
947
+ if (consumeNodes()) return "nodes";
948
+ if (
949
+ candidate === null ||
950
+ typeof candidate === "string" ||
951
+ typeof candidate === "boolean" ||
952
+ (typeof candidate === "number" && Number.isFinite(candidate))
953
+ ) {
954
+ continue;
955
+ }
956
+ if (!candidate || typeof candidate !== "object") {
957
+ invalid = true;
958
+ continue;
959
+ }
960
+
961
+ try {
962
+ if (isProxy(candidate) || seen.has(candidate)) {
963
+ invalid = true;
964
+ continue;
965
+ }
966
+ seen.add(candidate);
967
+ } catch {
968
+ invalid = true;
969
+ continue;
970
+ }
971
+
972
+ const depth = current.parentDepth + 1;
973
+ if (depth > limitDepth) return "depth";
974
+
975
+ if (Array.isArray(candidate)) {
976
+ let lengthDescriptor;
977
+ let keys;
978
+ try {
979
+ lengthDescriptor = Object.getOwnPropertyDescriptor(candidate, "length");
980
+ if (
981
+ !lengthDescriptor ||
982
+ !Object.hasOwn(lengthDescriptor, "value") ||
983
+ !Number.isSafeInteger(lengthDescriptor.value) ||
984
+ lengthDescriptor.value < 0
985
+ ) {
986
+ invalid = true;
987
+ continue;
988
+ }
989
+ if (nodes + lengthDescriptor.value > limitNodes) return "nodes";
990
+ keys = Reflect.ownKeys(candidate);
991
+ } catch {
992
+ invalid = true;
993
+ continue;
994
+ }
995
+ if (
996
+ keys.length !== lengthDescriptor.value + 1 ||
997
+ keys.some((key) => typeof key === "symbol")
998
+ ) {
999
+ invalid = true;
1000
+ continue;
1001
+ }
1002
+ for (let index = 0; index < lengthDescriptor.value; index += 1) {
1003
+ const descriptor = Object.getOwnPropertyDescriptor(
1004
+ candidate,
1005
+ String(index),
1006
+ );
1007
+ if (
1008
+ !descriptor ||
1009
+ !Object.hasOwn(descriptor, "value") ||
1010
+ descriptor.enumerable !== true
1011
+ ) {
1012
+ invalid = true;
1013
+ continue;
1014
+ }
1015
+ stack.push({ value: descriptor.value, parentDepth: depth });
1016
+ }
1017
+ continue;
1018
+ }
1019
+
1020
+ let prototype;
1021
+ let keys;
1022
+ try {
1023
+ prototype = Object.getPrototypeOf(candidate);
1024
+ keys = Reflect.ownKeys(candidate);
1025
+ } catch {
1026
+ invalid = true;
1027
+ continue;
1028
+ }
1029
+ if (
1030
+ (prototype !== Object.prototype && prototype !== null) ||
1031
+ keys.some((key) => typeof key === "symbol")
1032
+ ) {
1033
+ invalid = true;
1034
+ continue;
1035
+ }
1036
+ if (consumeNodes(keys.length)) return "nodes";
1037
+ for (const key of keys) {
1038
+ let descriptor;
1039
+ try {
1040
+ descriptor = Object.getOwnPropertyDescriptor(candidate, key);
1041
+ } catch {
1042
+ invalid = true;
1043
+ continue;
1044
+ }
1045
+ if (
1046
+ !descriptor ||
1047
+ !Object.hasOwn(descriptor, "value") ||
1048
+ descriptor.enumerable !== true
1049
+ ) {
1050
+ invalid = true;
1051
+ continue;
1052
+ }
1053
+ stack.push({ value: descriptor.value, parentDepth: depth });
1054
+ }
1055
+ }
1056
+ return invalid ? "invalid" : null;
1057
+ }
1058
+
1059
+ function parseMcpJson(text, serverName, entry) {
1060
+ const wireIssue = inspectMcpJsonWireBudget(text, entry?.config);
1061
+ if (wireIssue) throw mcpJsonBudgetError(serverName, entry, wireIssue);
1062
+ const parsed = JSON.parse(text);
1063
+ const graphIssue = inspectMcpJsonGraphBudget(parsed, entry?.config);
1064
+ if (graphIssue === "depth" || graphIssue === "nodes") {
1065
+ throw mcpJsonBudgetError(serverName, entry, graphIssue);
1066
+ }
1067
+ return parsed;
1068
+ }
1069
+
1070
+ function isMcpJsonBudgetError(error) {
1071
+ const code = safeRpcProperty(error, "code");
1072
+ return (
1073
+ code === "CC_MCP_JSON_DEPTH_EXCEEDED" ||
1074
+ code === "CC_MCP_JSON_NODES_EXCEEDED"
1075
+ );
1076
+ }
1077
+
1078
+ // Host-owned UTF-8 byte ceiling for every newline-delimited stdio JSON-RPC
1079
+ // frame, including its unterminated tail. maxBufferChars remains a compatible
1080
+ // way to request a smaller limit, but cannot disable or raise this boundary.
1081
+ const MCP_STDIO_FRAME_MAX_BYTES = 16 * 1024 * 1024;
1082
+ const MCP_STDIO_MALFORMED_MAX_BYTES = 1024 * 1024;
1083
+ const MCP_STDIO_MALFORMED_MAX_FRAMES = 32;
1084
+ const MCP_STDIO_STDERR_MAX_BYTES = 1024 * 1024;
1085
+
1086
+ function stdioFrameByteLimit(configuredLimit) {
1087
+ if (Number.isFinite(configuredLimit) && configuredLimit > 0) {
1088
+ return Math.min(
1089
+ MCP_STDIO_FRAME_MAX_BYTES,
1090
+ Math.max(1, Math.floor(configuredLimit)),
1091
+ );
1092
+ }
1093
+ return MCP_STDIO_FRAME_MAX_BYTES;
1094
+ }
269
1095
 
270
1096
  /**
271
1097
  * Is this a TRANSIENT error worth a short retry during capability discovery?
@@ -275,7 +1101,8 @@ const MCP_MAX_BUFFER_CHARS = 16 * 1024 * 1024;
275
1101
  * retrying those just wastes attempts.
276
1102
  */
277
1103
  export function isTransientMcpError(err) {
278
- const msg = String((err && err.message) || err || "");
1104
+ if (isMcpRpcError(err) || isMcpNonRetryableProtocolError(err)) return false;
1105
+ const msg = safeRpcErrorText(err);
279
1106
  if (/HTTP 4\d\d\b/i.test(msg)) return false; // any 4xx is permanent here
280
1107
  if (/Request timeout/i.test(msg)) return false; // already spent the full budget
281
1108
  return /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|socket hang up|network error|HTTP 5\d\d\b/i.test(
@@ -297,6 +1124,7 @@ export class MCPClient extends EventEmitter {
297
1124
  constructor(options = {}) {
298
1125
  super();
299
1126
  this.servers = new Map(); // name → { process, state, tools, resources, config }
1127
+ this._toolMetadataBytes = 0;
300
1128
  this._nextId = 1;
301
1129
  this._reconnectors = new Map(); // name → async () => config|null
302
1130
  this._reconnecting = new Map(); // name → in-flight reconnect promise
@@ -319,6 +1147,8 @@ export class MCPClient extends EventEmitter {
319
1147
  ? options.elicitationHandler
320
1148
  : null;
321
1149
  this._elicitationHandlers = new Map();
1150
+ this._elicitationHandlerAbortCleanup = null;
1151
+ this._elicitationHandlerAbortCleanups = new Map();
322
1152
  this._elicitationContext = new AsyncLocalStorage();
323
1153
  this._elicitationTimeoutMs =
324
1154
  Number(options.elicitationTimeoutMs) > 0
@@ -326,6 +1156,7 @@ export class MCPClient extends EventEmitter {
326
1156
  : 180000;
327
1157
  this._pendingElicitations = new Map();
328
1158
  this._urlElicitations = new Map();
1159
+ this._elicitationDisconnectGuards = new Map();
329
1160
  this._activeElicitations = 0;
330
1161
  this._maxConcurrentElicitations =
331
1162
  Number(options.maxConcurrentElicitations) > 0
@@ -342,24 +1173,96 @@ export class MCPClient extends EventEmitter {
342
1173
  : null;
343
1174
  }
344
1175
 
345
- /** Install or clear the host-side MCP elicitation resolver. */
1176
+ /**
1177
+ * Install or clear the host-side MCP elicitation resolver. An optional
1178
+ * caller signal owns that route and retires the background HTTP/SSE channel
1179
+ * once no resolver remains.
1180
+ */
346
1181
  setElicitationHandler(handler, options = {}) {
347
1182
  const sessionId = options.sessionId;
1183
+ const callerSignal = options.signal || null;
1184
+ const install = typeof handler === "function" && !callerSignal?.aborted;
348
1185
  if (sessionId != null && sessionId !== "") {
349
1186
  const key = String(sessionId);
350
- if (typeof handler === "function")
351
- this._elicitationHandlers.set(key, handler);
1187
+ try {
1188
+ this._elicitationHandlerAbortCleanups.get(key)?.();
1189
+ } catch {
1190
+ // Best-effort cleanup for non-native AbortSignal adapters.
1191
+ }
1192
+ this._elicitationHandlerAbortCleanups.delete(key);
1193
+ if (install) this._elicitationHandlers.set(key, handler);
352
1194
  else this._elicitationHandlers.delete(key);
353
1195
  } else {
354
- this._elicitationHandler = typeof handler === "function" ? handler : null;
1196
+ try {
1197
+ this._elicitationHandlerAbortCleanup?.();
1198
+ } catch {
1199
+ // Best-effort cleanup for non-native AbortSignal adapters.
1200
+ }
1201
+ this._elicitationHandlerAbortCleanup = null;
1202
+ this._elicitationHandler = install ? handler : null;
355
1203
  }
356
1204
  if (Number(options.timeoutMs) > 0) {
357
1205
  this._elicitationTimeoutMs = Number(options.timeoutMs);
358
1206
  }
359
- if (typeof handler === "function") {
1207
+
1208
+ if (install && callerSignal?.addEventListener) {
1209
+ const key =
1210
+ sessionId != null && sessionId !== "" ? String(sessionId) : null;
1211
+ const onCallerAbort = () => {
1212
+ if (key != null) {
1213
+ if (this._elicitationHandlers.get(key) === handler) {
1214
+ this.clearElicitationHandler(key);
1215
+ }
1216
+ } else if (this._elicitationHandler === handler) {
1217
+ this.setElicitationHandler(null);
1218
+ }
1219
+ };
1220
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
1221
+ const cleanup = () =>
1222
+ callerSignal.removeEventListener?.("abort", onCallerAbort);
1223
+ if (key != null) {
1224
+ this._elicitationHandlerAbortCleanups.set(key, cleanup);
1225
+ } else {
1226
+ this._elicitationHandlerAbortCleanup = cleanup;
1227
+ }
1228
+ // Cover a custom signal that changes between preflight and listener
1229
+ // installation. Identity checks keep replacement handlers intact.
1230
+ if (callerSignal.aborted) onCallerAbort();
1231
+ }
1232
+ this._syncHttpMessageStreams();
1233
+ }
1234
+
1235
+ _hasElicitationHandler() {
1236
+ return Boolean(
1237
+ this._elicitationHandler || this._elicitationHandlers.size > 0,
1238
+ );
1239
+ }
1240
+
1241
+ _stopHttpMessageStream(entry) {
1242
+ const stream = entry?._httpMessageStream;
1243
+ if (!stream) return false;
1244
+ stream.stopped = true;
1245
+ try {
1246
+ stream.controller?.abort();
1247
+ } catch {
1248
+ // The stopped flag still prevents dispatch/reconnect.
1249
+ }
1250
+ stream.wake?.();
1251
+ if (entry._httpMessageStream === stream) {
1252
+ entry._httpMessageStream = null;
1253
+ }
1254
+ return true;
1255
+ }
1256
+
1257
+ _syncHttpMessageStreams() {
1258
+ if (this._hasElicitationHandler()) {
360
1259
  for (const name of this.servers.keys()) {
361
1260
  this._ensureHttpMessageStream(name);
362
1261
  }
1262
+ return;
1263
+ }
1264
+ for (const entry of this.servers.values()) {
1265
+ this._stopHttpMessageStream(entry);
363
1266
  }
364
1267
  }
365
1268
 
@@ -375,7 +1278,16 @@ export class MCPClient extends EventEmitter {
375
1278
  /** Remove a session route when a WS session is closed. */
376
1279
  clearElicitationHandler(sessionId) {
377
1280
  if (sessionId == null || sessionId === "") return false;
378
- return this._elicitationHandlers.delete(String(sessionId));
1281
+ const key = String(sessionId);
1282
+ try {
1283
+ this._elicitationHandlerAbortCleanups.get(key)?.();
1284
+ } catch {
1285
+ // Best-effort cleanup for non-native AbortSignal adapters.
1286
+ }
1287
+ this._elicitationHandlerAbortCleanups.delete(key);
1288
+ const removed = this._elicitationHandlers.delete(key);
1289
+ this._syncHttpMessageStreams();
1290
+ return removed;
379
1291
  }
380
1292
 
381
1293
  /** Resolve a pending server elicitation by its server/request id pair. */
@@ -406,6 +1318,74 @@ export class MCPClient extends EventEmitter {
406
1318
  });
407
1319
  }
408
1320
 
1321
+ _awaitElicitationHandler(serverName, handler, request) {
1322
+ const name = String(serverName);
1323
+ const disconnectedResult = { kind: "disconnected" };
1324
+ let wasDisconnected = false;
1325
+ let signalDisconnect;
1326
+ const disconnected = new Promise((resolve) => {
1327
+ signalDisconnect = () => {
1328
+ wasDisconnected = true;
1329
+ resolve(disconnectedResult);
1330
+ };
1331
+ });
1332
+ let guards = this._elicitationDisconnectGuards.get(name);
1333
+ if (!guards) {
1334
+ guards = new Set();
1335
+ this._elicitationDisconnectGuards.set(name, guards);
1336
+ }
1337
+ guards.add(signalDisconnect);
1338
+
1339
+ const handled = Promise.resolve()
1340
+ .then(() =>
1341
+ wasDisconnected ? disconnectedResult : handler({ ...request }),
1342
+ )
1343
+ .then(
1344
+ (value) =>
1345
+ value === disconnectedResult
1346
+ ? disconnectedResult
1347
+ : { kind: "response", value },
1348
+ (error) => ({ kind: "error", error }),
1349
+ );
1350
+ return Promise.race([handled, disconnected]).finally(() => {
1351
+ guards.delete(signalDisconnect);
1352
+ if (
1353
+ guards.size === 0 &&
1354
+ this._elicitationDisconnectGuards.get(name) === guards
1355
+ ) {
1356
+ this._elicitationDisconnectGuards.delete(name);
1357
+ }
1358
+ });
1359
+ }
1360
+
1361
+ _cancelServerElicitations(serverName) {
1362
+ const name = String(serverName);
1363
+ const guards = this._elicitationDisconnectGuards.get(name);
1364
+ if (guards) {
1365
+ this._elicitationDisconnectGuards.delete(name);
1366
+ for (const signalDisconnect of [...guards]) signalDisconnect();
1367
+ guards.clear();
1368
+ }
1369
+
1370
+ for (const pending of [...this._pendingElicitations.values()]) {
1371
+ if (String(pending.request?.server) !== name) continue;
1372
+ this.cancelElicitation(name, pending.request?.requestId);
1373
+ }
1374
+
1375
+ for (const entry of this._urlElicitations.values()) {
1376
+ if (
1377
+ String(entry.server) !== name ||
1378
+ (entry.status !== "pending" && entry.status !== "accepted")
1379
+ ) {
1380
+ continue;
1381
+ }
1382
+ entry.status = "cancel";
1383
+ entry.completionNotified = false;
1384
+ for (const waiter of [...entry.waiters]) waiter(false);
1385
+ entry.waiters.clear();
1386
+ }
1387
+ }
1388
+
409
1389
  _elicitationKey(serverName, requestId) {
410
1390
  return `${String(serverName)}:${String(requestId)}`;
411
1391
  }
@@ -495,9 +1475,26 @@ export class MCPClient extends EventEmitter {
495
1475
  this._elicitationHandler;
496
1476
  if (handler) {
497
1477
  try {
498
- const direct = await handler({
499
- ...request,
500
- });
1478
+ const outcome = await this._awaitElicitationHandler(
1479
+ serverName,
1480
+ handler,
1481
+ request,
1482
+ );
1483
+ if (outcome.kind === "disconnected") {
1484
+ const response = { action: "cancel" };
1485
+ this._settleUrlElicitation(request, response);
1486
+ try {
1487
+ this._runtimeProducer?.store.acknowledgeInbox(
1488
+ `mcp-elicitation:${key}`,
1489
+ { response },
1490
+ );
1491
+ } catch {
1492
+ // Runtime inbox acknowledgement is advisory.
1493
+ }
1494
+ return response;
1495
+ }
1496
+ if (outcome.kind === "error") throw outcome.error;
1497
+ const direct = outcome.value;
501
1498
  if (
502
1499
  direct !== undefined &&
503
1500
  String(direct?.action || "").toLowerCase() !== "defer"
@@ -589,33 +1586,30 @@ export class MCPClient extends EventEmitter {
589
1586
  const id = request?.elicitationId;
590
1587
  if (!id) return;
591
1588
  const existing = this._urlElicitations.get(id);
592
- if (existing && existing.server !== request.server) {
593
- throw new Error(`MCP URL elicitation id collision across servers: ${id}`);
594
- }
595
- if (existing && existing.request?.url !== request.url) {
596
- throw new Error(
597
- `MCP URL elicitation id reused with a different URL: ${id}`,
598
- );
1589
+ if (existing) {
1590
+ const error = new Error("MCP URL elicitation id was already used");
1591
+ error.code = "CC_MCP_ELICITATION_ID_REUSED";
1592
+ throw error;
599
1593
  }
600
- if (!existing) {
601
- this._urlElicitations.set(id, {
602
- id,
603
- server: request.server,
604
- request,
605
- status: "pending",
606
- completionNotified: false,
607
- waiters: new Set(),
608
- });
609
- while (this._urlElicitations.size > 1000) {
610
- this._urlElicitations.delete(this._urlElicitations.keys().next().value);
611
- }
1594
+ if (this._urlElicitations.size >= MCP_URL_ELICITATION_HISTORY_MAX_COUNT) {
1595
+ const error = new Error("MCP URL elicitation history is full");
1596
+ error.code = "CC_MCP_ELICITATION_HISTORY_FULL";
1597
+ throw error;
612
1598
  }
1599
+ this._urlElicitations.set(id, {
1600
+ id,
1601
+ server: request.server,
1602
+ request,
1603
+ status: "pending",
1604
+ completionNotified: false,
1605
+ waiters: new Set(),
1606
+ });
613
1607
  }
614
1608
 
615
1609
  _settleUrlElicitation(request, response) {
616
1610
  if (request?.mode !== "url" || !request.elicitationId) return;
617
1611
  const entry = this._urlElicitations.get(request.elicitationId);
618
- if (!entry || entry.status === "completed") return;
1612
+ if (!entry || entry.status !== "pending") return;
619
1613
  entry.status =
620
1614
  response?.action === "accept"
621
1615
  ? "accepted"
@@ -836,15 +1830,88 @@ export class MCPClient extends EventEmitter {
836
1830
  if (this.servers.has(name)) {
837
1831
  throw new Error(`Server "${name}" already connected`);
838
1832
  }
839
-
840
- const transportKind = inferTransport(config);
841
- const sourceConfig = config;
1833
+
1834
+ const transportKind = inferTransport(config);
1835
+ let stdioExecutionApproval = null;
1836
+ let approvedStdioInvocation = null;
1837
+ if (transportKind === "stdio") {
1838
+ if (!config?.command) {
1839
+ throw new Error(
1840
+ `stdio transport requires a command (server "${name}")`,
1841
+ );
1842
+ }
1843
+ stdioExecutionApproval = _deps.consumeMcpStdioExecutionAuthority(
1844
+ config?.mcpStdioExecutionAuthority,
1845
+ { serverName: name, config },
1846
+ );
1847
+ approvedStdioInvocation = _deps.materializeApprovedMcpStdioInvocation(
1848
+ stdioExecutionApproval,
1849
+ { serverName: name, config },
1850
+ );
1851
+ }
1852
+ const {
1853
+ mcpStdioExecutionAuthority: _stdioExecutionAuthority,
1854
+ ...untrustedSourceConfig
1855
+ } = config;
1856
+ const sourceConfig = approvedStdioInvocation
1857
+ ? { ...untrustedSourceConfig, ...approvedStdioInvocation }
1858
+ : untrustedSourceConfig;
1859
+ let pluginWorkspaceRoot = null;
1860
+ let stdioExecutableIdentity = null;
1861
+ let stdioExecutableIdentityAuthority = null;
1862
+ let stdioExecutableEnvironment = null;
1863
+ let stdioExecutableWorkingDirectory = null;
1864
+ let stdioExecutableIdentityDigest = null;
1865
+ let stdioCapsuleSandboxExecutionContract = null;
1866
+ let runtimeConfig = sourceConfig;
1867
+ if (transportKind === "stdio") {
1868
+ const isPlugin = sourceConfig.origin === "plugin:mcp";
1869
+ pluginWorkspaceRoot = isPlugin
1870
+ ? resolvePluginWorkspaceAuthority(
1871
+ sourceConfig.pluginWorkspaceAuthority,
1872
+ {
1873
+ origin: sourceConfig.origin,
1874
+ pluginId: sourceConfig.pluginId,
1875
+ pluginVersion: sourceConfig.pluginVersion,
1876
+ pluginSource: sourceConfig.pluginSource,
1877
+ },
1878
+ )
1879
+ : null;
1880
+ if (
1881
+ isPlugin &&
1882
+ sourceConfig.sandboxPolicy?.requiredBoundaries?.length > 0 &&
1883
+ !pluginWorkspaceRoot
1884
+ ) {
1885
+ throw new Error(
1886
+ `plugin MCP server "${name}" is missing its trusted workspace authority`,
1887
+ );
1888
+ }
1889
+ const prepared = _deps.prepareMcpStdioExecutableIdentity({
1890
+ serverName: name,
1891
+ config: sourceConfig,
1892
+ approval: stdioExecutionApproval,
1893
+ cwd: pluginWorkspaceRoot || process.cwd(),
1894
+ env: { ...process.env, ...this._agentIdentityEnv() },
1895
+ });
1896
+ runtimeConfig = {
1897
+ ...sourceConfig,
1898
+ command: prepared.command,
1899
+ args: [...prepared.args],
1900
+ };
1901
+ stdioExecutableIdentity = prepared.identity;
1902
+ stdioExecutableIdentityAuthority = prepared.authority;
1903
+ stdioExecutableEnvironment = prepared.env;
1904
+ stdioExecutableWorkingDirectory = prepared.workingDirectory;
1905
+ stdioExecutableIdentityDigest = prepared.identityDigest;
1906
+ stdioCapsuleSandboxExecutionContract =
1907
+ prepared.sandboxExecutionContract || null;
1908
+ }
842
1909
  const connectionHeaders = await this._connectionHeaders(
843
1910
  name,
844
1911
  sourceConfig,
845
1912
  transportKind,
846
1913
  );
847
- config = { ...sourceConfig, headers: connectionHeaders };
1914
+ config = { ...runtimeConfig, headers: connectionHeaders };
848
1915
  const entry = {
849
1916
  // Keep the source config (static headers + helper command) so every
850
1917
  // reconnect can execute the helper afresh instead of reusing credentials.
@@ -858,6 +1925,16 @@ export class MCPClient extends EventEmitter {
858
1925
  httpSessionId: null,
859
1926
  protocolVersion: null,
860
1927
  _httpMessageStream: null,
1928
+ _httpRequestControllers: new Set(),
1929
+ _httpDiscardControllers: new Set(),
1930
+ _httpDiscardStopping: false,
1931
+ _disconnectPromise: null,
1932
+ _webSocketPayloadError: null,
1933
+ _webSocketInboundError: null,
1934
+ _inboundTrafficBudget: null,
1935
+ _toolMetadataBytes: 0,
1936
+ _stdioExecutionApproval: stdioExecutionApproval,
1937
+ _stdioExecutableIdentity: stdioExecutableIdentity,
861
1938
  tools: [],
862
1939
  resources: [],
863
1940
  resourceTemplates: [],
@@ -865,12 +1942,17 @@ export class MCPClient extends EventEmitter {
865
1942
  resourceSubscriptions: new Set(),
866
1943
  _pending: new Map(),
867
1944
  _buffer: "",
868
- // Per-connection streaming decoders so a multi-byte UTF-8 character (e.g.
869
- // a 3-byte Chinese char) split across two stdout/stderr chunks is
1945
+ _bufferBytes: 0,
1946
+ _stdioFrameError: null,
1947
+ _malformedFrameBytes: 0,
1948
+ _malformedFrameCount: 0,
1949
+ _stderrBytes: 0,
1950
+ _stderrNotified: false,
1951
+ // Per-connection streaming decoder so a multi-byte UTF-8 character (e.g.
1952
+ // a 3-byte Chinese char) split across two stdout chunks is
870
1953
  // reassembled instead of corrupted into U+FFFD. Decoding each chunk
871
1954
  // independently (data.toString("utf8")) would mangle a split character.
872
1955
  _decoder: new TextDecoder("utf-8"),
873
- _stderrDecoder: new TextDecoder("utf-8"),
874
1956
  };
875
1957
 
876
1958
  const rejectPending = (error) => {
@@ -935,9 +2017,7 @@ export class MCPClient extends EventEmitter {
935
2017
  const connectTimeoutMs = Number.isFinite(config.connectTimeoutMs)
936
2018
  ? Math.max(1, config.connectTimeoutMs)
937
2019
  : WEBSOCKET_CONNECT_TIMEOUT_MS;
938
- const maxPayload = Number.isFinite(config.maxPayloadBytes)
939
- ? Math.max(1024, config.maxPayloadBytes)
940
- : WEBSOCKET_MAX_PAYLOAD_BYTES;
2020
+ const maxPayload = webSocketPayloadLimit(config.maxPayloadBytes);
941
2021
  const Socket = _deps.WebSocket;
942
2022
  const socket = new Socket(config.url, {
943
2023
  headers: { ...(config.headers || {}) },
@@ -950,6 +2030,72 @@ export class MCPClient extends EventEmitter {
950
2030
  });
951
2031
  entry.socket = socket;
952
2032
 
2033
+ const recordPayloadFailure = (closeCode = 1009) => {
2034
+ if (entry._webSocketPayloadError) {
2035
+ return entry._webSocketPayloadError;
2036
+ }
2037
+ const error = mcpWebSocketPayloadTooLargeError(name, {
2038
+ transport: transportKind,
2039
+ url: config.url,
2040
+ limitBytes: maxPayload,
2041
+ closeCode,
2042
+ });
2043
+ entry._webSocketPayloadError = error;
2044
+ entry.state = ServerState.ERROR;
2045
+ rejectPending(error);
2046
+ this._cancelServerElicitations(name);
2047
+ this.emit("server-error", {
2048
+ name,
2049
+ error: error.message,
2050
+ code: error.code,
2051
+ closeCode: error.closeCode,
2052
+ limitBytes: error.limitBytes,
2053
+ });
2054
+ if (socket.readyState === 1) {
2055
+ try {
2056
+ socket.close(1009, "payload too large");
2057
+ } catch {
2058
+ socket.terminate?.();
2059
+ }
2060
+ }
2061
+ return error;
2062
+ };
2063
+ const recordInboundFailure = (error) => {
2064
+ if (entry._webSocketInboundError) {
2065
+ return entry._webSocketInboundError;
2066
+ }
2067
+ entry._webSocketInboundError = error;
2068
+ entry.state = ServerState.ERROR;
2069
+ rejectPending(error);
2070
+ this._cancelServerElicitations(name);
2071
+ this.emit("server-error", {
2072
+ name,
2073
+ error: error.message,
2074
+ code: error.code,
2075
+ ...(error.limitBytes != null
2076
+ ? { limitBytes: error.limitBytes }
2077
+ : {}),
2078
+ ...(error.limitMessages != null
2079
+ ? { limitMessages: error.limitMessages }
2080
+ : {}),
2081
+ ...(error.limitDepth != null
2082
+ ? { limitDepth: error.limitDepth }
2083
+ : {}),
2084
+ ...(error.limitNodes != null
2085
+ ? { limitNodes: error.limitNodes }
2086
+ : {}),
2087
+ ...(error.windowMs != null ? { windowMs: error.windowMs } : {}),
2088
+ });
2089
+ if (socket.readyState === 1) {
2090
+ try {
2091
+ socket.close(1008, "inbound budget exceeded");
2092
+ } catch {
2093
+ socket.terminate?.();
2094
+ }
2095
+ }
2096
+ return error;
2097
+ };
2098
+
953
2099
  await new Promise((resolve, reject) => {
954
2100
  let settled = false;
955
2101
  const finish = (fn, value) => {
@@ -989,6 +2135,7 @@ export class MCPClient extends EventEmitter {
989
2135
  });
990
2136
 
991
2137
  socket.on("message", (data, isBinary) => {
2138
+ if (entry._webSocketInboundError) return;
992
2139
  if (isBinary) {
993
2140
  const error = mcpTransportError(
994
2141
  "CC_MCP_WS_BINARY_MESSAGE",
@@ -997,6 +2144,7 @@ export class MCPClient extends EventEmitter {
997
2144
  );
998
2145
  entry.state = ServerState.ERROR;
999
2146
  rejectPending(error);
2147
+ this._cancelServerElicitations(name);
1000
2148
  this.emit("server-error", {
1001
2149
  name,
1002
2150
  error: error.message,
@@ -1005,8 +2153,23 @@ export class MCPClient extends EventEmitter {
1005
2153
  socket.close(1003, "JSON text required");
1006
2154
  return;
1007
2155
  }
2156
+ const inboundBytes =
2157
+ typeof data === "string"
2158
+ ? Buffer.byteLength(data, "utf8")
2159
+ : Number.isFinite(data?.byteLength)
2160
+ ? data.byteLength
2161
+ : Buffer.byteLength(String(data || ""), "utf8");
2162
+ const inboundError = recordMcpInboundTraffic(
2163
+ name,
2164
+ entry,
2165
+ inboundBytes,
2166
+ );
2167
+ if (inboundError) {
2168
+ recordInboundFailure(inboundError);
2169
+ return;
2170
+ }
1008
2171
  try {
1009
- const message = JSON.parse(String(data));
2172
+ const message = parseMcpJson(String(data), name, entry);
1010
2173
  if (
1011
2174
  !message ||
1012
2175
  Array.isArray(message) ||
@@ -1016,13 +2179,18 @@ export class MCPClient extends EventEmitter {
1016
2179
  }
1017
2180
  this._handleMessage(name, message);
1018
2181
  } catch (cause) {
2182
+ if (isMcpJsonBudgetError(cause)) {
2183
+ recordInboundFailure(cause);
2184
+ return;
2185
+ }
1019
2186
  const error = mcpTransportError(
1020
2187
  "CC_MCP_WS_INVALID_MESSAGE",
1021
- `Invalid WebSocket JSON-RPC message: ${cause?.message || cause}`,
2188
+ "MCP WebSocket server sent an invalid JSON-RPC message",
1022
2189
  { transport: transportKind, url: config.url },
1023
2190
  );
1024
2191
  entry.state = ServerState.ERROR;
1025
2192
  rejectPending(error);
2193
+ this._cancelServerElicitations(name);
1026
2194
  this.emit("server-error", {
1027
2195
  name,
1028
2196
  code: error.code,
@@ -1031,29 +2199,97 @@ export class MCPClient extends EventEmitter {
1031
2199
  socket.close(1007, "Invalid JSON-RPC message");
1032
2200
  }
1033
2201
  });
1034
- socket.on("close", (code, reason) => {
2202
+ socket.on("close", (code) => {
2203
+ const closeCode =
2204
+ Number.isInteger(code) && code >= 0 && code <= 65535 ? code : null;
2205
+ // Only a local ws payload error proves that this client rejected an
2206
+ // inbound message over its host cap. A peer can send close 1009 when
2207
+ // it rejected our outbound data, or for any private policy reason;
2208
+ // the code alone does not establish direction or local limit breach.
2209
+ const payloadError = entry._webSocketPayloadError;
2210
+ const inboundError = entry._webSocketInboundError;
2211
+ if (inboundError) {
2212
+ entry.state = ServerState.ERROR;
2213
+ rejectPending(inboundError);
2214
+ this._cancelServerElicitations(name);
2215
+ this.emit("server-disconnected", {
2216
+ name,
2217
+ code: closeCode,
2218
+ reason: "inbound_budget_exceeded",
2219
+ errorCode: inboundError.code,
2220
+ ...(inboundError.limitBytes != null
2221
+ ? { limitBytes: inboundError.limitBytes }
2222
+ : {}),
2223
+ ...(inboundError.limitMessages != null
2224
+ ? { limitMessages: inboundError.limitMessages }
2225
+ : {}),
2226
+ ...(inboundError.limitDepth != null
2227
+ ? { limitDepth: inboundError.limitDepth }
2228
+ : {}),
2229
+ ...(inboundError.limitNodes != null
2230
+ ? { limitNodes: inboundError.limitNodes }
2231
+ : {}),
2232
+ ...(inboundError.windowMs != null
2233
+ ? { windowMs: inboundError.windowMs }
2234
+ : {}),
2235
+ });
2236
+ return;
2237
+ }
2238
+ if (payloadError) {
2239
+ entry.state = ServerState.ERROR;
2240
+ rejectPending(payloadError);
2241
+ this._cancelServerElicitations(name);
2242
+ this.emit("server-disconnected", {
2243
+ name,
2244
+ code: closeCode,
2245
+ reason: "payload too large",
2246
+ errorCode: payloadError.code,
2247
+ limitBytes: payloadError.limitBytes,
2248
+ });
2249
+ return;
2250
+ }
1035
2251
  entry.state = ServerState.DISCONNECTED;
1036
- const suffix = reason?.length ? `: ${String(reason)}` : "";
2252
+ const dispatched = entry._pending.size > 0;
1037
2253
  const error = mcpTransportError(
1038
2254
  "CC_MCP_WS_CLOSED",
1039
- `MCP WebSocket server "${name}" disconnected (code ${code})${suffix}`,
1040
- { transport: transportKind, url: config.url, closeCode: code },
2255
+ `MCP WebSocket server "${name}" disconnected (code ${closeCode ?? "unknown"})`,
2256
+ {
2257
+ transport: transportKind,
2258
+ url: config.url,
2259
+ closeCode,
2260
+ dispatched,
2261
+ outcomeUnknown: dispatched,
2262
+ },
1041
2263
  );
1042
2264
  rejectPending(error);
2265
+ this._cancelServerElicitations(name);
1043
2266
  this.emit("server-disconnected", {
1044
2267
  name,
1045
- code,
1046
- reason: String(reason || ""),
2268
+ code: closeCode,
2269
+ reason: "peer_closed",
2270
+ errorCode: error.code,
1047
2271
  });
1048
2272
  });
1049
2273
  socket.on("error", (cause) => {
2274
+ if (entry._webSocketInboundError) return;
2275
+ if (isWebSocketPayloadError(cause)) {
2276
+ recordPayloadFailure(1009);
2277
+ return;
2278
+ }
1050
2279
  entry.state = ServerState.ERROR;
2280
+ const dispatched = entry._pending.size > 0;
1051
2281
  const error = mcpTransportError(
1052
2282
  "CC_MCP_WS_ERROR",
1053
- `MCP WebSocket server "${name}" error: ${cause?.message || cause}`,
1054
- { transport: transportKind, url: config.url },
2283
+ `MCP WebSocket transport failed for server "${name}"`,
2284
+ {
2285
+ transport: transportKind,
2286
+ url: config.url,
2287
+ dispatched,
2288
+ outcomeUnknown: dispatched,
2289
+ },
1055
2290
  );
1056
2291
  rejectPending(error);
2292
+ this._cancelServerElicitations(name);
1057
2293
  this.emit("server-error", {
1058
2294
  name,
1059
2295
  error: error.message,
@@ -1067,29 +2303,15 @@ export class MCPClient extends EventEmitter {
1067
2303
  );
1068
2304
  }
1069
2305
  const isPlugin = config.origin === "plugin:mcp";
1070
- const pluginWorkspaceRoot = isPlugin
1071
- ? resolvePluginWorkspaceAuthority(config.pluginWorkspaceAuthority, {
1072
- origin: config.origin,
1073
- pluginId: config.pluginId,
1074
- pluginVersion: config.pluginVersion,
1075
- pluginSource: config.pluginSource,
1076
- })
1077
- : null;
1078
- if (
1079
- isPlugin &&
1080
- config.sandboxPolicy?.requiredBoundaries?.length > 0 &&
1081
- !pluginWorkspaceRoot
1082
- ) {
1083
- throw new Error(
1084
- `plugin MCP server "${name}" is missing its trusted workspace authority`,
1085
- );
1086
- }
1087
2306
  const spawnOptions = {
1088
- cwd: pluginWorkspaceRoot || process.cwd(),
2307
+ cwd:
2308
+ stdioExecutableWorkingDirectory ||
2309
+ pluginWorkspaceRoot ||
2310
+ process.cwd(),
1089
2311
  stdio: ["pipe", "pipe", "pipe"],
1090
2312
  // process.env < agent identity (CLAUDECODE / session id) < the
1091
2313
  // server's own config.env, so an explicit per-server override wins.
1092
- env: {
2314
+ env: stdioExecutableEnvironment || {
1093
2315
  ...process.env,
1094
2316
  ...this._agentIdentityEnv(),
1095
2317
  ...(config.env || {}),
@@ -1098,28 +2320,66 @@ export class MCPClient extends EventEmitter {
1098
2320
  policy: config.policy || "allow",
1099
2321
  scope: config.scope || "mcp",
1100
2322
  shell: false,
2323
+ windowsHide: true,
2324
+ // POSIX owns an independent process group. Windows requires the
2325
+ // Broker's Job Object boundary so an unexpectedly exiting MCP root
2326
+ // cannot orphan grandchildren before taskkill can enumerate them.
2327
+ detached: process.platform !== "win32",
2328
+ ...(process.platform === "win32" ||
2329
+ stdioCapsuleSandboxExecutionContract
2330
+ ? {
2331
+ requiredBoundaries: [
2332
+ ...(process.platform === "win32" ? ["process-tree"] : []),
2333
+ ...(stdioCapsuleSandboxExecutionContract
2334
+ ? [MCP_STDIO_CAPSULE_CODE_SNAPSHOT_BOUNDARY]
2335
+ : []),
2336
+ ],
2337
+ }
2338
+ : {}),
1101
2339
  pluginId: config.pluginId,
1102
2340
  pluginVersion: config.pluginVersion,
1103
2341
  pluginSource: config.pluginSource,
1104
2342
  ...(config.sandboxPolicy
1105
2343
  ? { sandboxPolicy: config.sandboxPolicy }
1106
2344
  : {}),
2345
+ mcpStdioExecutableIdentityAuthority: stdioExecutableIdentityAuthority,
2346
+ mcpStdioExecutableIdentityDigest: stdioExecutableIdentityDigest,
1107
2347
  };
1108
2348
  const sandboxExecutionContract =
1109
- !isPlugin || pluginWorkspaceRoot
2349
+ stdioCapsuleSandboxExecutionContract ||
2350
+ (!isPlugin || pluginWorkspaceRoot
1110
2351
  ? executionBroker.issueLinuxWorkspaceSandboxExecutionContract(
1111
2352
  config.command,
1112
2353
  config.args || [],
1113
- spawnOptions,
1114
- pluginWorkspaceRoot || process.cwd(),
2354
+ { ...spawnOptions, detached: false },
2355
+ stdioExecutableWorkingDirectory ||
2356
+ pluginWorkspaceRoot ||
2357
+ process.cwd(),
1115
2358
  )
1116
- : null;
1117
- const proc = _deps.spawn(config.command, config.args || [], {
2359
+ : null);
2360
+ const capsuleUsesPosixProcessGroup =
2361
+ Boolean(stdioCapsuleSandboxExecutionContract) &&
2362
+ process.platform !== "win32";
2363
+ const launchOptions = {
1118
2364
  ...spawnOptions,
2365
+ detached:
2366
+ sandboxExecutionContract && !capsuleUsesPosixProcessGroup
2367
+ ? false
2368
+ : spawnOptions.detached,
1119
2369
  ...(sandboxExecutionContract ? { sandboxExecutionContract } : {}),
2370
+ };
2371
+ const proc = _deps.spawn(config.command, config.args || [], {
2372
+ ...launchOptions,
1120
2373
  });
1121
2374
 
1122
2375
  entry.process = proc;
2376
+ entry._stdioTreeMode =
2377
+ (sandboxExecutionContract && !capsuleUsesPosixProcessGroup) ||
2378
+ (process.platform === "win32" && proc?.sandboxReady?.then)
2379
+ ? "sandbox"
2380
+ : process.platform === "win32"
2381
+ ? "windows-tree"
2382
+ : "posix-group";
1123
2383
 
1124
2384
  proc.stdout.on("data", (data) => {
1125
2385
  this._handleData(
@@ -1131,13 +2391,7 @@ export class MCPClient extends EventEmitter {
1131
2391
  });
1132
2392
 
1133
2393
  proc.stderr.on("data", (data) => {
1134
- this.emit("server-error", {
1135
- name,
1136
- error:
1137
- typeof data === "string"
1138
- ? data
1139
- : entry._stderrDecoder.decode(data, { stream: true }),
1140
- });
2394
+ this._handleStdioStderr(name, data);
1141
2395
  });
1142
2396
 
1143
2397
  // If the server process dies with requests in flight, reject them
@@ -1156,16 +2410,44 @@ export class MCPClient extends EventEmitter {
1156
2410
  };
1157
2411
 
1158
2412
  proc.on("close", (code) => {
1159
- entry.state = ServerState.DISCONNECTED;
2413
+ // Preserve a host-detected fatal framing state after the process kill
2414
+ // it initiated; a subsequent close event must not downgrade ERROR to
2415
+ // an ordinary peer disconnect.
2416
+ if (!entry._stdioFrameError) {
2417
+ entry.state = ServerState.DISCONNECTED;
2418
+ }
1160
2419
  failPending(
1161
2420
  `MCP server "${name}" process exited (code ${code}) before responding`,
1162
2421
  );
2422
+ this._cancelServerElicitations(name);
1163
2423
  this.emit("server-disconnected", { name, code });
2424
+ // The root may exit while leaving descendants alive. A Broker-owned
2425
+ // namespace/Job close is already a tree fence; an unmanaged POSIX
2426
+ // process group is still signalled and probed by the same bounded
2427
+ // retirement path used by disconnect().
2428
+ void retireStdioEntryProcess(entry, { alreadyClosed: true }).then(
2429
+ (cleanup) => {
2430
+ if (cleanup?.verifiable && !cleanup.confirmed) {
2431
+ entry.state = ServerState.ERROR;
2432
+ const cleanupError = mcpStdioCleanupError(name, cleanup);
2433
+ this.emit("server-error", {
2434
+ name,
2435
+ error: cleanupError.message,
2436
+ code: cleanupError.code,
2437
+ });
2438
+ }
2439
+ },
2440
+ () => {
2441
+ // The production terminator is total. Keep a rejection from a
2442
+ // test-injected dependency from becoming an unhandled promise.
2443
+ },
2444
+ );
1164
2445
  });
1165
2446
 
1166
2447
  proc.on("error", (err) => {
1167
2448
  entry.state = ServerState.ERROR;
1168
2449
  failPending(`MCP server "${name}" process error: ${err.message}`);
2450
+ this._cancelServerElicitations(name);
1169
2451
  this.emit("server-error", { name, error: err.message });
1170
2452
  });
1171
2453
 
@@ -1179,6 +2461,7 @@ export class MCPClient extends EventEmitter {
1179
2461
  proc.stdin.on("error", (err) => {
1180
2462
  entry.state = ServerState.ERROR;
1181
2463
  failPending(`MCP server "${name}" stdin error: ${err.message}`);
2464
+ this._cancelServerElicitations(name);
1182
2465
  this.emit("server-error", { name, error: err.message });
1183
2466
  });
1184
2467
  }
@@ -1238,7 +2521,7 @@ export class MCPClient extends EventEmitter {
1238
2521
  "tools/list",
1239
2522
  {},
1240
2523
  );
1241
- entry.tools = toolsResult?.tools || [];
2524
+ this._replaceToolInventory(name, entry, toolsResult?.tools || []);
1242
2525
  } catch (err) {
1243
2526
  if (advertisesTools) {
1244
2527
  entry.toolsError = err?.message || String(err);
@@ -1302,20 +2585,44 @@ export class MCPClient extends EventEmitter {
1302
2585
  };
1303
2586
  } catch (err) {
1304
2587
  entry.state = ServerState.ERROR;
2588
+ this._cancelServerElicitations(name);
2589
+ // The initialize notification is fire-and-forget. If later capability
2590
+ // discovery fails, connect() deletes this entry without going through
2591
+ // disconnect(), so explicitly reap any response-discard request first.
2592
+ entry._httpDiscardStopping = true;
2593
+ _abortHttpRequests(entry);
2594
+ _abortHttpDiscardRequests(entry);
1305
2595
  // A stdio child spawned above but failed to initialize (handshake
1306
2596
  // timeout / broken pipe / non-MCP command) would otherwise leak: we
1307
2597
  // delete the entry from `this.servers` below, so disconnect() can never
1308
2598
  // reach it, and an alive-but-unresponsive process fires neither `close`
1309
2599
  // nor `error` — it would run orphaned with its stdio listeners bound for
1310
- // the lifetime of the CLI. Tear it down on the way out (best-effort;
1311
- // never mask the original connect error).
2600
+ // the lifetime of the CLI. Retire the owned tree on the way out without
2601
+ // masking the original connect error.
1312
2602
  if (entry.process) {
2603
+ let cleanup = null;
2604
+ try {
2605
+ cleanup = await retireStdioEntryProcess(entry, {
2606
+ alreadyClosed:
2607
+ entry.process.exitCode !== null &&
2608
+ entry.process.exitCode !== undefined,
2609
+ });
2610
+ } catch {
2611
+ // Preserve the original connection failure if a test-injected
2612
+ // cleanup dependency rejects. The production terminator is total.
2613
+ }
2614
+ if (
2615
+ cleanup &&
2616
+ err !== null &&
2617
+ (typeof err === "object" || typeof err === "function")
2618
+ ) {
2619
+ err.processTreeCleanup = cleanup;
2620
+ }
1313
2621
  try {
1314
2622
  entry.process.stdout?.removeAllListeners();
1315
2623
  entry.process.stderr?.removeAllListeners();
1316
2624
  entry.process.stdin?.removeAllListeners?.();
1317
2625
  entry.process.removeAllListeners();
1318
- entry.process.kill();
1319
2626
  } catch {
1320
2627
  // teardown is best-effort — the connect error is what matters
1321
2628
  }
@@ -1332,6 +2639,7 @@ export class MCPClient extends EventEmitter {
1332
2639
  // teardown is best-effort
1333
2640
  }
1334
2641
  }
2642
+ this._releaseToolInventory(entry);
1335
2643
  this.servers.delete(name);
1336
2644
  if (
1337
2645
  !_authRetryUsed &&
@@ -1350,59 +2658,77 @@ export class MCPClient extends EventEmitter {
1350
2658
  /**
1351
2659
  * Disconnect from an MCP server.
1352
2660
  */
1353
- async disconnect(name) {
2661
+ disconnect(name) {
1354
2662
  const entry = this.servers.get(name);
1355
- if (!entry) return false;
1356
-
1357
- for (const [key, pending] of this._pendingElicitations) {
1358
- if (key.startsWith(`${String(name)}:`)) {
1359
- pending.resolve({ action: "cancel" });
1360
- }
2663
+ if (!entry) {
2664
+ this._cancelServerElicitations(name);
2665
+ return Promise.resolve(false);
1361
2666
  }
2667
+ if (entry._disconnectPromise) return entry._disconnectPromise;
1362
2668
 
1363
- if (entry.process) {
1364
- entry.process.kill();
1365
- }
1366
- if (entry.socket) {
1367
- entry._rejectPending?.(
1368
- mcpTransportError(
1369
- "CC_MCP_WS_CLIENT_DISCONNECT",
1370
- `MCP WebSocket server "${name}" disconnected by client`,
1371
- { transport: entry.transportKind, url: entry.config?.url },
1372
- ),
1373
- );
1374
- try {
1375
- entry.socket.close(1000, "client disconnect");
1376
- } catch {
1377
- entry.socket.terminate?.();
1378
- }
1379
- }
1380
- if (entry._httpMessageStream) {
1381
- entry._httpMessageStream.stopped = true;
1382
- entry._httpMessageStream.controller?.abort();
1383
- entry._httpMessageStream.wake?.();
1384
- entry._httpMessageStream = null;
1385
- }
1386
- // HTTP sessions: best-effort DELETE to free server-side state.
1387
- if (entry.httpUrl && entry.httpSessionId) {
2669
+ // Make disconnect visible before any teardown awaits. In particular, an
2670
+ // HTTP session DELETE may wait for response headers; no new tool/resource
2671
+ // request may enter the transport during that window or trigger a hot
2672
+ // reconnect that resurrects a server the caller is intentionally closing.
2673
+ entry.state = ServerState.DISCONNECTED;
2674
+ entry._httpDiscardStopping = true;
2675
+ this._cancelServerElicitations(name);
2676
+ const disconnecting = Promise.resolve().then(async () => {
1388
2677
  try {
1389
- await _deps.fetch(entry.httpUrl, {
1390
- method: "DELETE",
1391
- headers: {
1392
- ...(entry.httpHeaders || {}),
1393
- "Mcp-Session-Id": entry.httpSessionId,
1394
- "MCP-Protocol-Version":
1395
- entry.protocolVersion || this._protocolVersion,
1396
- },
1397
- });
1398
- } catch {
1399
- // ignore — disconnect is best-effort
2678
+ if (entry.process) {
2679
+ const cleanup = await retireStdioEntryProcess(entry);
2680
+ if (cleanup?.verifiable && !cleanup.confirmed) {
2681
+ throw mcpStdioCleanupError(name, cleanup);
2682
+ }
2683
+ }
2684
+ if (entry.socket) {
2685
+ entry._rejectPending?.(
2686
+ mcpTransportError(
2687
+ "CC_MCP_WS_CLIENT_DISCONNECT",
2688
+ `MCP WebSocket server "${name}" disconnected by client`,
2689
+ { transport: entry.transportKind, url: entry.config?.url },
2690
+ ),
2691
+ );
2692
+ try {
2693
+ entry.socket.close(1000, "client disconnect");
2694
+ } catch {
2695
+ entry.socket.terminate?.();
2696
+ }
2697
+ }
2698
+ this._stopHttpMessageStream(entry);
2699
+ // HTTP sessions: best-effort DELETE to free server-side state.
2700
+ if (entry.httpUrl && entry.httpSessionId) {
2701
+ try {
2702
+ await _fetchAndDiscardHttpResponse(entry, {
2703
+ method: "DELETE",
2704
+ headers: {
2705
+ ...(entry.httpHeaders || {}),
2706
+ "Mcp-Session-Id": entry.httpSessionId,
2707
+ "MCP-Protocol-Version":
2708
+ entry.protocolVersion || this._protocolVersion,
2709
+ },
2710
+ });
2711
+ } catch {
2712
+ // ignore — disconnect is best-effort
2713
+ }
2714
+ }
2715
+ return true;
2716
+ } finally {
2717
+ entry.state = ServerState.DISCONNECTED;
2718
+ this._releaseToolInventory(entry);
2719
+ // A future connection may reuse the same name. Never let a delayed
2720
+ // teardown delete that replacement entry.
2721
+ if (this.servers.get(name) === entry) this.servers.delete(name);
1400
2722
  }
1401
- }
1402
-
1403
- entry.state = ServerState.DISCONNECTED;
1404
- this.servers.delete(name);
1405
- return true;
2723
+ });
2724
+ entry._disconnectPromise = disconnecting;
2725
+
2726
+ // Abort already-issued fire-and-forget POSTs synchronously after the
2727
+ // single-flight marker is installed, so abort listeners cannot re-enter a
2728
+ // second teardown.
2729
+ _abortHttpRequests(entry);
2730
+ _abortHttpDiscardRequests(entry);
2731
+ return disconnecting;
1406
2732
  }
1407
2733
 
1408
2734
  /**
@@ -1415,6 +2741,36 @@ export class MCPClient extends EventEmitter {
1415
2741
  }
1416
2742
  }
1417
2743
 
2744
+ _replaceToolInventory(serverName, entry, tools) {
2745
+ const previousBytes = Number.isSafeInteger(entry?._toolMetadataBytes)
2746
+ ? entry._toolMetadataBytes
2747
+ : 0;
2748
+ const clientBytesUsed = Math.max(
2749
+ 0,
2750
+ this._toolMetadataBytes - previousBytes,
2751
+ );
2752
+ const admitted = admitMcpToolList(serverName, tools, entry?.config, {
2753
+ clientBytesUsed,
2754
+ });
2755
+ entry.tools = admitted.tools;
2756
+ entry._toolMetadataBytes = admitted.metadataBytes;
2757
+ this._toolMetadataBytes = clientBytesUsed + admitted.metadataBytes;
2758
+ return entry.tools;
2759
+ }
2760
+
2761
+ _releaseToolInventory(entry) {
2762
+ const bytes = Number.isSafeInteger(entry?._toolMetadataBytes)
2763
+ ? entry._toolMetadataBytes
2764
+ : 0;
2765
+ if (bytes > 0) {
2766
+ this._toolMetadataBytes = Math.max(0, this._toolMetadataBytes - bytes);
2767
+ }
2768
+ if (entry) {
2769
+ entry._toolMetadataBytes = 0;
2770
+ entry.tools = [];
2771
+ }
2772
+ }
2773
+
1418
2774
  /**
1419
2775
  * List all connected servers.
1420
2776
  */
@@ -1462,24 +2818,43 @@ export class MCPClient extends EventEmitter {
1462
2818
  * @param {string} serverName - Server name
1463
2819
  * @param {string} toolName - Tool name
1464
2820
  * @param {object} args - Tool arguments
2821
+ * @param {{signal?: AbortSignal}} options - Host-owned cancellation
1465
2822
  */
1466
- async callTool(serverName, toolName, args = {}) {
2823
+ async callTool(serverName, toolName, args = {}, options = {}) {
2824
+ if (options?.signal?.aborted) {
2825
+ throw mcpRequestAbortedError(
2826
+ serverName,
2827
+ this.servers.get(serverName),
2828
+ false,
2829
+ );
2830
+ }
1467
2831
  try {
1468
- return await this._callToolOnce(serverName, toolName, args);
2832
+ return await this._callToolOnce(serverName, toolName, args, options);
1469
2833
  } catch (err) {
1470
2834
  if (
1471
- err?.code === URL_ELICITATION_REQUIRED &&
2835
+ mcpRpcControlData.has(err) &&
1472
2836
  (await this._resolveRequiredUrlElicitations(serverName, err))
1473
2837
  ) {
1474
2838
  // Retry exactly once after every out-of-band flow reports completion.
1475
- return this._callToolOnce(serverName, toolName, args);
2839
+ return this._callToolOnce(serverName, toolName, args, options);
1476
2840
  }
2841
+ if (safeRpcProperty(err, "outcomeUnknown") === true) throw err;
1477
2842
  if (
1478
2843
  !this._reconnectors.has(serverName) &&
1479
2844
  !this.servers.get(serverName)?.config?.headersHelper
1480
2845
  ) {
1481
2846
  throw err;
1482
2847
  }
2848
+ if (options?.signal?.aborted && isLikelyConnectionError(err)) {
2849
+ // A network failure and host cancellation can race after dispatch.
2850
+ // Cancellation wins over hot reconnect so an outcome-unknown tool is
2851
+ // never replayed after its owning turn has already stopped.
2852
+ throw mcpRequestAbortedError(
2853
+ serverName,
2854
+ this.servers.get(serverName),
2855
+ true,
2856
+ );
2857
+ }
1483
2858
  if (!isLikelyConnectionError(err)) {
1484
2859
  throw err;
1485
2860
  }
@@ -1490,24 +2865,37 @@ export class MCPClient extends EventEmitter {
1490
2865
  connectAuthRetryUsed: true,
1491
2866
  });
1492
2867
  if (!reconnected) throw err;
1493
- return await this._callToolOnce(serverName, toolName, args);
2868
+ return await this._callToolOnce(serverName, toolName, args, options);
1494
2869
  }
1495
2870
  }
1496
2871
 
1497
2872
  async _resolveRequiredUrlElicitations(serverName, error) {
1498
- const requested = Array.isArray(error?.data?.elicitations)
1499
- ? error.data.elicitations
1500
- : [];
1501
- if (requested.length === 0 || requested.length > 16) return false;
2873
+ const requested = mcpRpcControlData.get(error)?.elicitations;
2874
+ if (
2875
+ !Array.isArray(requested) ||
2876
+ requested.length === 0 ||
2877
+ requested.length > MCP_RPC_URL_ELICITATION_MAX_COUNT
2878
+ ) {
2879
+ return false;
2880
+ }
1502
2881
  const normalized = [];
1503
2882
  const ids = new Set();
1504
2883
  for (const params of requested) {
1505
2884
  let request;
1506
2885
  try {
2886
+ // -32042 is the sole RPC error whose data drives client behavior.
2887
+ // Admit only the protocol fields needed by URL elicitation; arbitrary
2888
+ // peer data must not hitchhike into handlers/events via object spread.
2889
+ const candidate = {
2890
+ mode: params.mode,
2891
+ elicitationId: params.elicitationId,
2892
+ url: params.url,
2893
+ message: params.message,
2894
+ };
1507
2895
  request = normalizeMcpElicitationRequest(
1508
2896
  serverName,
1509
- `url-required:${params?.elicitationId || normalized.length}`,
1510
- params,
2897
+ `url-required:${candidate.elicitationId || normalized.length}`,
2898
+ candidate,
1511
2899
  );
1512
2900
  } catch {
1513
2901
  return false;
@@ -1517,13 +2905,31 @@ export class MCPClient extends EventEmitter {
1517
2905
  ids.add(request.elicitationId);
1518
2906
  normalized.push(request);
1519
2907
  }
2908
+ // Completion notifications are correlated by elicitationId alone. Reusing
2909
+ // any retained id could let a prior completed generation authorize an
2910
+ // automatic retry, so reject the whole batch before prompting.
2911
+ if (
2912
+ normalized.some((request) =>
2913
+ this._urlElicitations.has(request.elicitationId),
2914
+ )
2915
+ ) {
2916
+ return false;
2917
+ }
1520
2918
  const accepted = [];
1521
2919
  for (const request of normalized) {
1522
- const response = await this._resolveElicitation(
1523
- serverName,
1524
- request.requestId,
1525
- request,
1526
- );
2920
+ let response;
2921
+ try {
2922
+ response = await this._resolveElicitation(
2923
+ serverName,
2924
+ request.requestId,
2925
+ request,
2926
+ );
2927
+ } catch {
2928
+ // A collision can race the preflight check. Preserve the original,
2929
+ // host-sanitized -32042 error instead of replacing it with any
2930
+ // elicitation detail or handler failure.
2931
+ return false;
2932
+ }
1527
2933
  if (response.action !== "accept") return false;
1528
2934
  accepted.push(request);
1529
2935
  }
@@ -1538,19 +2944,27 @@ export class MCPClient extends EventEmitter {
1538
2944
  return completed.every(Boolean);
1539
2945
  }
1540
2946
 
1541
- async _callToolOnce(serverName, toolName, args) {
2947
+ async _callToolOnce(serverName, toolName, args, options = {}) {
1542
2948
  const entry = this.servers.get(serverName);
1543
2949
  if (!entry) throw new Error(`Server "${serverName}" not found`);
2950
+ if (entry._httpDiscardStopping) {
2951
+ throw mcpServerDisconnectingError(serverName, entry);
2952
+ }
1544
2953
  if (entry.state !== ServerState.CONNECTED) {
1545
2954
  throw new Error(`Server "${serverName}" is not connected`);
1546
2955
  }
1547
2956
 
1548
- const result = await this._sendRequest(serverName, "tools/call", {
1549
- name: toolName,
1550
- arguments: args,
1551
- });
2957
+ const result = await this._sendRequest(
2958
+ serverName,
2959
+ "tools/call",
2960
+ {
2961
+ name: toolName,
2962
+ arguments: args,
2963
+ },
2964
+ options,
2965
+ );
1552
2966
 
1553
- return result;
2967
+ return admitMcpToolResult(serverName, result, entry.config).result;
1554
2968
  }
1555
2969
 
1556
2970
  /**
@@ -1565,7 +2979,8 @@ export class MCPClient extends EventEmitter {
1565
2979
  if (inFlight) return inFlight;
1566
2980
  const p = (async () => {
1567
2981
  try {
1568
- const currentConfig = this.servers.get(name)?.config || null;
2982
+ const currentEntry = this.servers.get(name) || null;
2983
+ const currentConfig = currentEntry?.config || null;
1569
2984
  const resolver = this._reconnectors.get(name);
1570
2985
  const fresh = resolver ? await resolver() : currentConfig;
1571
2986
  if (!fresh) return false;
@@ -1574,7 +2989,20 @@ export class MCPClient extends EventEmitter {
1574
2989
  } catch {
1575
2990
  // entry may already be gone — connect() below is what matters
1576
2991
  }
1577
- await this.connect(name, fresh, options.connectAuthRetryUsed === true);
2992
+ let reconnectConfig = fresh;
2993
+ if (inferTransport(fresh) === "stdio") {
2994
+ reconnectConfig = { ...fresh };
2995
+ reconnectConfig.mcpStdioExecutionAuthority =
2996
+ _deps.renewMcpStdioExecutionAuthority(
2997
+ currentEntry?._stdioExecutionApproval,
2998
+ { serverName: name, config: reconnectConfig },
2999
+ );
3000
+ }
3001
+ await this.connect(
3002
+ name,
3003
+ reconnectConfig,
3004
+ options.connectAuthRetryUsed === true,
3005
+ );
1578
3006
  this.emit("server-reconnected", { name, url: fresh.url || null });
1579
3007
  return true;
1580
3008
  } catch {
@@ -1609,14 +3037,20 @@ export class MCPClient extends EventEmitter {
1609
3037
 
1610
3038
  /**
1611
3039
  * Read a resource from a server.
3040
+ * @param {string} serverName
3041
+ * @param {string} uri
3042
+ * @param {{signal?: AbortSignal}} options
1612
3043
  */
1613
- async readResource(serverName, uri) {
3044
+ async readResource(serverName, uri, options = {}) {
1614
3045
  const entry = this.servers.get(serverName);
1615
3046
  if (!entry) throw new Error(`Server "${serverName}" not found`);
1616
3047
 
1617
- const result = await this._sendRequest(serverName, "resources/read", {
1618
- uri,
1619
- });
3048
+ const result = await this._sendRequest(
3049
+ serverName,
3050
+ "resources/read",
3051
+ { uri },
3052
+ options,
3053
+ );
1620
3054
  return result;
1621
3055
  }
1622
3056
 
@@ -1756,16 +3190,23 @@ export class MCPClient extends EventEmitter {
1756
3190
  }
1757
3191
  }
1758
3192
 
1759
- _sendRequest(serverName, method, params) {
3193
+ _sendRequest(serverName, method, params, options = {}) {
1760
3194
  const entry = this.servers.get(serverName);
1761
3195
  if (!entry) return Promise.reject(new Error("Server not available"));
3196
+ const callerSignal = options?.signal || null;
3197
+ if (callerSignal?.aborted) {
3198
+ return Promise.reject(mcpRequestAbortedError(serverName, entry, false));
3199
+ }
3200
+ if (entry._httpDiscardStopping) {
3201
+ return Promise.reject(mcpServerDisconnectingError(serverName, entry));
3202
+ }
1762
3203
 
1763
3204
  if (entry.httpUrl) {
1764
- return this._sendHttpRequest(serverName, method, params);
3205
+ return this._sendHttpRequest(serverName, method, params, options);
1765
3206
  }
1766
3207
 
1767
3208
  if (entry.socket) {
1768
- return this._sendWebSocketRequest(serverName, method, params);
3209
+ return this._sendWebSocketRequest(serverName, method, params, options);
1769
3210
  }
1770
3211
 
1771
3212
  return new Promise((resolve, reject) => {
@@ -1781,7 +3222,58 @@ export class MCPClient extends EventEmitter {
1781
3222
  params,
1782
3223
  });
1783
3224
 
1784
- entry._pending.set(id, { resolve, reject });
3225
+ let timeout = null;
3226
+ let removeCallerAbort = null;
3227
+ let dispatched = false;
3228
+ let settled = false;
3229
+ const cleanup = () => {
3230
+ if (timeout) clearTimeout(timeout);
3231
+ try {
3232
+ removeCallerAbort?.();
3233
+ } catch {
3234
+ // Best-effort cleanup for non-native AbortSignal adapters.
3235
+ }
3236
+ entry._pending.delete(id);
3237
+ };
3238
+ const settle = (settler, value) => {
3239
+ if (settled) return false;
3240
+ settled = true;
3241
+ cleanup();
3242
+ settler(value);
3243
+ return true;
3244
+ };
3245
+ const pending = {
3246
+ resolve: (value) => settle(resolve, value),
3247
+ reject: (error) => settle(reject, error),
3248
+ };
3249
+ entry._pending.set(id, pending);
3250
+
3251
+ if (callerSignal?.addEventListener) {
3252
+ const onCallerAbort = () => {
3253
+ const wasDispatched = dispatched;
3254
+ if (
3255
+ !settle(
3256
+ reject,
3257
+ mcpRequestAbortedError(serverName, entry, wasDispatched),
3258
+ )
3259
+ ) {
3260
+ return;
3261
+ }
3262
+ if (wasDispatched) {
3263
+ this._sendRequestCancellation(
3264
+ serverName,
3265
+ method,
3266
+ id,
3267
+ `Request cancelled by caller: ${method}`,
3268
+ );
3269
+ }
3270
+ };
3271
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
3272
+ removeCallerAbort = () =>
3273
+ callerSignal.removeEventListener?.("abort", onCallerAbort);
3274
+ if (callerSignal.aborted) onCallerAbort();
3275
+ }
3276
+ if (settled) return;
1785
3277
 
1786
3278
  // Per-call timeout — honour the same config knobs as the HTTP path so the
1787
3279
  // two transports behave consistently: `longRunning` servers (a tool that
@@ -1796,33 +3288,31 @@ export class MCPClient extends EventEmitter {
1796
3288
  const timeoutMs = Number.isFinite(entry.config?.requestTimeoutMs)
1797
3289
  ? entry.config.requestTimeoutMs
1798
3290
  : STDIO_REQUEST_TIMEOUT_MS;
1799
- let timeout = null;
1800
3291
  if (!longRunning && timeoutMs > 0) {
1801
3292
  timeout = setTimeout(() => {
1802
- if (!entry._pending.delete(id)) return;
1803
3293
  const timeoutError = new Error(`Request timeout: ${method}`);
1804
- this._sendTimeoutCancellation(
3294
+ if (!settle(reject, timeoutError)) return;
3295
+ this._sendRequestCancellation(
1805
3296
  serverName,
1806
3297
  method,
1807
3298
  id,
1808
3299
  timeoutError.message,
1809
3300
  );
1810
- reject(timeoutError);
1811
3301
  }, timeoutMs);
1812
- entry._pending.get(id).timeout = timeout;
3302
+ timeout.unref?.();
3303
+ pending.timeout = timeout;
1813
3304
  }
1814
3305
 
1815
3306
  try {
3307
+ dispatched = true;
1816
3308
  entry.process.stdin.write(message + "\n");
1817
3309
  } catch (err) {
1818
- clearTimeout(timeout); // clearTimeout(null) is a safe no-op
1819
- entry._pending.delete(id);
1820
- reject(err);
3310
+ settle(reject, err);
1821
3311
  }
1822
3312
  });
1823
3313
  }
1824
3314
 
1825
- _sendWebSocketRequest(serverName, method, params) {
3315
+ _sendWebSocketRequest(serverName, method, params, options = {}) {
1826
3316
  const entry = this.servers.get(serverName);
1827
3317
  if (!entry?.socket || entry.socket.readyState !== 1) {
1828
3318
  return Promise.reject(
@@ -1833,6 +3323,10 @@ export class MCPClient extends EventEmitter {
1833
3323
  ),
1834
3324
  );
1835
3325
  }
3326
+ const callerSignal = options?.signal || null;
3327
+ if (callerSignal?.aborted) {
3328
+ return Promise.reject(mcpRequestAbortedError(serverName, entry, false));
3329
+ }
1836
3330
 
1837
3331
  return new Promise((resolve, reject) => {
1838
3332
  const id = this._nextId++;
@@ -1842,40 +3336,104 @@ export class MCPClient extends EventEmitter {
1842
3336
  ? entry.config.requestTimeoutMs
1843
3337
  : STDIO_REQUEST_TIMEOUT_MS;
1844
3338
  let timeout = null;
3339
+ let removeCallerAbort = null;
3340
+ let dispatched = false;
3341
+ let settled = false;
3342
+ const cleanup = () => {
3343
+ if (timeout) clearTimeout(timeout);
3344
+ try {
3345
+ removeCallerAbort?.();
3346
+ } catch {
3347
+ // Best-effort cleanup for non-native AbortSignal adapters.
3348
+ }
3349
+ entry._pending.delete(id);
3350
+ };
3351
+ const settle = (settler, value) => {
3352
+ if (settled) return false;
3353
+ settled = true;
3354
+ cleanup();
3355
+ settler(value);
3356
+ return true;
3357
+ };
3358
+ const pending = {
3359
+ resolve: (value) => settle(resolve, value),
3360
+ reject: (error) => settle(reject, error),
3361
+ };
3362
+ entry._pending.set(id, pending);
3363
+
3364
+ if (callerSignal?.addEventListener) {
3365
+ const onCallerAbort = () => {
3366
+ const wasDispatched = dispatched;
3367
+ if (
3368
+ !settle(
3369
+ reject,
3370
+ mcpRequestAbortedError(serverName, entry, wasDispatched),
3371
+ )
3372
+ ) {
3373
+ return;
3374
+ }
3375
+ if (wasDispatched) {
3376
+ this._sendRequestCancellation(
3377
+ serverName,
3378
+ method,
3379
+ id,
3380
+ `Request cancelled by caller: ${method}`,
3381
+ );
3382
+ }
3383
+ };
3384
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
3385
+ removeCallerAbort = () =>
3386
+ callerSignal.removeEventListener?.("abort", onCallerAbort);
3387
+ if (callerSignal.aborted) onCallerAbort();
3388
+ }
3389
+ if (settled) return;
3390
+
1845
3391
  if (!longRunning && timeoutMs > 0) {
1846
3392
  timeout = setTimeout(() => {
1847
- if (!entry._pending.delete(id)) return;
1848
3393
  const timeoutError = mcpTransportError(
1849
3394
  "CC_MCP_WS_REQUEST_TIMEOUT",
1850
3395
  `Request timeout: ${method} (WebSocket, no response in ${timeoutMs}ms)`,
1851
3396
  { transport: entry.transportKind, url: entry.config?.url },
1852
3397
  );
1853
- this._sendTimeoutCancellation(
3398
+ if (!settle(reject, timeoutError)) return;
3399
+ this._sendRequestCancellation(
1854
3400
  serverName,
1855
3401
  method,
1856
3402
  id,
1857
3403
  timeoutError.message,
1858
3404
  );
1859
- reject(timeoutError);
1860
3405
  }, timeoutMs);
3406
+ timeout.unref?.();
3407
+ pending.timeout = timeout;
1861
3408
  }
1862
- entry._pending.set(id, { resolve, reject, timeout });
1863
- entry.socket.send(message, (cause) => {
1864
- if (!cause) return;
1865
- if (timeout) clearTimeout(timeout);
1866
- entry._pending.delete(id);
1867
- reject(
3409
+ const failDispatch = () => {
3410
+ settle(
3411
+ reject,
1868
3412
  mcpTransportError(
1869
3413
  "CC_MCP_WS_SEND_FAILED",
1870
- `WebSocket send failed for ${method}: ${cause.message}`,
1871
- { transport: entry.transportKind, url: entry.config?.url },
3414
+ `WebSocket request dispatch failed for method "${method}"`,
3415
+ {
3416
+ transport: entry.transportKind,
3417
+ url: entry.config?.url,
3418
+ dispatched: true,
3419
+ outcomeUnknown: true,
3420
+ },
1872
3421
  ),
1873
3422
  );
1874
- });
3423
+ };
3424
+ try {
3425
+ dispatched = true;
3426
+ entry.socket.send(message, (cause) => {
3427
+ if (!cause) return;
3428
+ failDispatch();
3429
+ });
3430
+ } catch {
3431
+ failDispatch();
3432
+ }
1875
3433
  });
1876
3434
  }
1877
3435
 
1878
- _sendTimeoutCancellation(serverName, method, requestId, reason) {
3436
+ _sendRequestCancellation(serverName, method, requestId, reason) {
1879
3437
  // The initialize handshake establishes the protocol/session needed for
1880
3438
  // later notifications, so it cannot itself be cancelled safely.
1881
3439
  if (method === "initialize") return;
@@ -1971,15 +3529,22 @@ export class MCPClient extends EventEmitter {
1971
3529
  ? { "Last-Event-ID": stream.lastEventId }
1972
3530
  : {}),
1973
3531
  };
1974
- const response = await _deps.fetch(entry.httpUrl, {
1975
- method: "GET",
1976
- headers,
1977
- ...(stream.controller
1978
- ? { signal: stream.controller.signal }
1979
- : {}),
1980
- });
1981
- if (response.status === 405) return;
3532
+ const response = await _waitForHttpMessageStreamResponse(
3533
+ _deps.fetch(entry.httpUrl, {
3534
+ method: "GET",
3535
+ headers,
3536
+ ...(stream.controller
3537
+ ? { signal: stream.controller.signal }
3538
+ : {}),
3539
+ }),
3540
+ stream.controller?.signal,
3541
+ );
3542
+ if (response.status === 405) {
3543
+ _cancelHttpResponseBody(response);
3544
+ return;
3545
+ }
1982
3546
  if (response.status === 401 || response.status === 403) {
3547
+ _cancelHttpResponseBody(response);
1983
3548
  if (entry.config?.headersHelper && stream.authFailures === 0) {
1984
3549
  stream.authFailures = 1;
1985
3550
  continue;
@@ -1992,6 +3557,7 @@ export class MCPClient extends EventEmitter {
1992
3557
  return;
1993
3558
  }
1994
3559
  if (!response.ok) {
3560
+ _cancelHttpResponseBody(response);
1995
3561
  throw new Error(
1996
3562
  `MCP HTTP message stream returned ${response.status}`,
1997
3563
  );
@@ -2000,21 +3566,55 @@ export class MCPClient extends EventEmitter {
2000
3566
  const contentType = response.headers?.get
2001
3567
  ? String(response.headers.get("content-type") || "").toLowerCase()
2002
3568
  : "";
2003
- if (!contentType.includes("text/event-stream")) return;
2004
- const cap = Number.isFinite(entry.config?.maxBufferChars)
2005
- ? entry.config.maxBufferChars
2006
- : MCP_MAX_BUFFER_CHARS;
3569
+ if (!contentType.includes("text/event-stream")) {
3570
+ _cancelHttpResponseBody(response);
3571
+ return;
3572
+ }
3573
+ const cap = _httpResponseByteLimit(entry.config?.maxBufferChars);
2007
3574
  await _consumeSseMessageStream(response, {
2008
3575
  cap,
2009
3576
  stream,
3577
+ eventTimeoutMs: _sseEventTimeout(entry.config?.requestTimeoutMs),
3578
+ onInbound: (bytes) => {
3579
+ const error = recordMcpInboundTraffic(serverName, entry, bytes);
3580
+ if (error) throw error;
3581
+ },
3582
+ parseJson: (text) => parseMcpJson(text, serverName, entry),
2010
3583
  onMessage: (message) => this._handleMessage(serverName, message),
2011
3584
  });
2012
3585
  } catch (error) {
2013
- if (stream.stopped || stream.controller?.signal.aborted) return;
3586
+ if (stream.stopped) return;
2014
3587
  this.emit("server-stream-error", {
2015
3588
  name: serverName,
2016
3589
  error: error?.message || String(error),
3590
+ ...(error?.code ? { code: error.code } : {}),
3591
+ ...(error?.limitBytes != null
3592
+ ? { limitBytes: error.limitBytes }
3593
+ : {}),
3594
+ ...(error?.timeoutMs != null
3595
+ ? { timeoutMs: error.timeoutMs }
3596
+ : {}),
3597
+ ...(error?.limitMessages != null
3598
+ ? { limitMessages: error.limitMessages }
3599
+ : {}),
3600
+ ...(error?.limitDepth != null
3601
+ ? { limitDepth: error.limitDepth }
3602
+ : {}),
3603
+ ...(error?.limitNodes != null
3604
+ ? { limitNodes: error.limitNodes }
3605
+ : {}),
3606
+ ...(error?.windowMs != null ? { windowMs: error.windowMs } : {}),
2017
3607
  });
3608
+ if (stream.controller?.signal.aborted) return;
3609
+ if (error?.code === "CC_MCP_HTTP_RESPONSE_TOO_LARGE") return;
3610
+ if (error?.code === "CC_MCP_HTTP_RESPONSE_STREAM_REQUIRED") {
3611
+ return;
3612
+ }
3613
+ if (error?.code === "CC_MCP_SSE_EVENT_TIMEOUT") return;
3614
+ if (error?.code === "CC_MCP_INBOUND_RATE_EXCEEDED") return;
3615
+ if (error?.code === "CC_MCP_INBOUND_TRAFFIC_EXCEEDED") return;
3616
+ if (error?.code === "CC_MCP_JSON_DEPTH_EXCEEDED") return;
3617
+ if (error?.code === "CC_MCP_JSON_NODES_EXCEEDED") return;
2018
3618
  if (String(error?.code || "").startsWith("CC_MCP_HEADERS_HELPER")) {
2019
3619
  return;
2020
3620
  }
@@ -2050,12 +3650,17 @@ export class MCPClient extends EventEmitter {
2050
3650
  * Accepts responses as either `application/json` or `text/event-stream`.
2051
3651
  * Captures `Mcp-Session-Id` header from the first response for reuse.
2052
3652
  */
2053
- async _sendHttpRequest(serverName, method, params) {
3653
+ async _sendHttpRequest(serverName, method, params, options = {}) {
2054
3654
  const entry = this.servers.get(serverName);
2055
3655
  if (!entry || !entry.httpUrl) {
2056
3656
  throw new Error("Server not available");
2057
3657
  }
2058
3658
 
3659
+ const callerSignal = options?.signal || null;
3660
+ if (callerSignal?.aborted) {
3661
+ throw mcpRequestAbortedError(serverName, entry, false);
3662
+ }
3663
+
2059
3664
  const id = this._nextId++;
2060
3665
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
2061
3666
  const headers = {
@@ -2071,37 +3676,106 @@ export class MCPClient extends EventEmitter {
2071
3676
  entry.protocolVersion || this._protocolVersion;
2072
3677
  }
2073
3678
 
2074
- // Per-call timeout (parity with the 30s stdio timeout) so a hung or dead
2075
- // HTTP MCP server can't block the request forever. Servers flagged
2076
- // longRunning — e.g. the IDE bridge, whose openDiff blocks on human review
2077
- // (see ideServerToMcpConfig) — are exempt. Override per server with
2078
- // config.requestTimeoutMs (0 disables).
3679
+ // Every request owns a controller so caller cancellation and disconnect
3680
+ // remain enforceable even when its optional deadline is disabled.
3681
+ // longRunning/requestTimeoutMs=0 suppress only the timeout timer.
2079
3682
  const longRunning = Boolean(entry.config && entry.config.longRunning);
2080
3683
  const timeoutMs = Number.isFinite(entry.config?.requestTimeoutMs)
2081
3684
  ? entry.config.requestTimeoutMs
2082
3685
  : HTTP_REQUEST_TIMEOUT_MS;
2083
- let controller = null;
3686
+ const controller =
3687
+ typeof AbortController === "function" ? new AbortController() : null;
3688
+ const request = controller
3689
+ ? {
3690
+ controller,
3691
+ abortKind: null,
3692
+ dispatched: false,
3693
+ response: null,
3694
+ }
3695
+ : null;
3696
+ const requests =
3697
+ entry._httpRequestControllers ||
3698
+ (entry._httpRequestControllers = new Set());
3699
+ if (request) requests.add(request);
2084
3700
  let timer = null;
3701
+ let responseTimer = null;
3702
+ let removeCallerAbort = null;
2085
3703
  const timeoutMessage = `Request timeout: ${method} (HTTP, no response in ${timeoutMs}ms)`;
2086
- if (
2087
- !longRunning &&
2088
- timeoutMs > 0 &&
2089
- typeof AbortController === "function"
2090
- ) {
2091
- controller = new AbortController();
3704
+ const callerAbortMessage = `Request cancelled by caller: ${method}`;
3705
+ const responseTimeoutMs = _httpResponseReadTimeout(
3706
+ entry.config?.requestTimeoutMs,
3707
+ );
3708
+ const responseTimeoutMessage = `MCP HTTP response body for method "${method}" exceeded the ${responseTimeoutMs}ms host deadline`;
3709
+
3710
+ const throwIfAborted = () => {
3711
+ if (!request?.abortKind) return;
3712
+ const response = request.response;
3713
+ request.response = null;
3714
+ _cancelHttpResponseBody(response);
3715
+ if (request.abortKind === "timeout") {
3716
+ throw new Error(timeoutMessage);
3717
+ }
3718
+ if (request.abortKind === "response_timeout") {
3719
+ throw mcpHttpResponseTimeoutError(
3720
+ serverName,
3721
+ entry,
3722
+ method,
3723
+ responseTimeoutMs,
3724
+ request.dispatched,
3725
+ );
3726
+ }
3727
+ if (request.abortKind === "caller") {
3728
+ throw mcpRequestAbortedError(serverName, entry, request.dispatched);
3729
+ }
3730
+ throw mcpServerDisconnectingError(serverName, entry, {
3731
+ dispatched: request.dispatched,
3732
+ });
3733
+ };
3734
+
3735
+ if (request && callerSignal?.addEventListener) {
3736
+ const onCallerAbort = () => {
3737
+ if (request.abortKind) return;
3738
+ request.abortKind = "caller";
3739
+ request.controller.abort();
3740
+ if (request.dispatched) {
3741
+ this._sendRequestCancellation(
3742
+ serverName,
3743
+ method,
3744
+ id,
3745
+ callerAbortMessage,
3746
+ );
3747
+ }
3748
+ };
3749
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
3750
+ removeCallerAbort = () =>
3751
+ callerSignal.removeEventListener?.("abort", onCallerAbort);
3752
+ // Cover a signal that changed between the preflight read and listener
3753
+ // installation (custom AbortSignal implementations may be re-entrant).
3754
+ if (callerSignal.aborted) onCallerAbort();
3755
+ }
3756
+
3757
+ if (!longRunning && timeoutMs > 0 && request) {
2092
3758
  timer = setTimeout(() => {
2093
- controller.abort();
2094
- this._sendTimeoutCancellation(serverName, method, id, timeoutMessage);
3759
+ if (request.abortKind) return;
3760
+ request.abortKind = "timeout";
3761
+ request.controller.abort();
3762
+ this._sendRequestCancellation(serverName, method, id, timeoutMessage);
2095
3763
  }, timeoutMs);
3764
+ timer.unref?.();
2096
3765
  }
2097
3766
 
2098
3767
  try {
2099
- const response = await _deps.fetch(entry.httpUrl, {
3768
+ throwIfAborted();
3769
+ const responsePromise = _deps.fetch(entry.httpUrl, {
2100
3770
  method: "POST",
2101
3771
  headers,
2102
3772
  body,
2103
3773
  ...(controller ? { signal: controller.signal } : {}),
2104
3774
  });
3775
+ if (request) request.dispatched = true;
3776
+ const response = await responsePromise;
3777
+ if (request) request.response = response;
3778
+ throwIfAborted();
2105
3779
 
2106
3780
  // Capture session id (server may emit on initialize response only)
2107
3781
  const sessionId =
@@ -2113,32 +3787,36 @@ export class MCPClient extends EventEmitter {
2113
3787
  entry.httpSessionId = sessionId;
2114
3788
  }
2115
3789
 
3790
+ // This is a host-owned absolute byte limit. maxBufferChars remains a
3791
+ // backwards-compatible way to request a smaller HTTP body limit, but 0
3792
+ // no longer disables the HTTP ceiling (stdio retains its old semantics).
3793
+ const cap = _httpResponseByteLimit(entry.config?.maxBufferChars);
3794
+
2116
3795
  if (!response.ok) {
2117
- // Authentication bodies frequently echo token/debug material. When a
2118
- // dynamic helper is configured, any error body may reflect its opaque
2119
- // header values; structured HTTP status is sufficient and cannot leak
2120
- // freshly generated credentials into logs or exceptions.
2121
- const suppressBody =
2122
- Boolean(entry.config?.headersHelper) ||
2123
- response.status === 401 ||
2124
- response.status === 403;
2125
- const text = suppressBody
2126
- ? ""
2127
- : typeof response.text === "function"
2128
- ? await response.text()
2129
- : "";
2130
- const detail = text ? `: ${text.slice(0, 200)}` : "";
3796
+ // An HTTP error body is untrusted peer data and can contain secrets,
3797
+ // terminal controls, prompt-injection text, or reflected request
3798
+ // headers. Never read or copy it into the Error object: downstream
3799
+ // hosts surface error.message to stderr, tool results, models and
3800
+ // sessions. Status + redacted endpoint are the complete diagnostic
3801
+ // contract; cancel the body immediately for every non-2xx response.
3802
+ _cancelHttpResponseBody(response);
2131
3803
  // 404 usually means a wrong/stale server URL — name it and point at the
2132
3804
  // MCP config (Claude-Code 2.1.191: "HTTP 404 errors now show the URL and
2133
3805
  // point to your MCP config") instead of a bare "HTTP 404".
2134
3806
  if (response.status === 404) {
2135
- throw new Error(
2136
- `HTTP 404${detail} — ${redactMcpUrl(entry.httpUrl)} returned Not Found; check this server's "url" in your MCP config`,
3807
+ throw mcpTransportError(
3808
+ "CC_MCP_HTTP_STATUS",
3809
+ `HTTP 404 — ${redactMcpUrl(entry.httpUrl)} returned Not Found; check this server's "url" in your MCP config`,
3810
+ {
3811
+ transport: entry.transportKind,
3812
+ url: entry.config?.url,
3813
+ status: response.status,
3814
+ },
2137
3815
  );
2138
3816
  }
2139
3817
  throw mcpTransportError(
2140
3818
  "CC_MCP_HTTP_STATUS",
2141
- `HTTP ${response.status}${detail}`,
3819
+ `HTTP ${response.status}`,
2142
3820
  {
2143
3821
  transport: entry.transportKind,
2144
3822
  url: entry.config?.url,
@@ -2147,46 +3825,90 @@ export class MCPClient extends EventEmitter {
2147
3825
  );
2148
3826
  }
2149
3827
 
3828
+ // The request-level timer is deliberately optional for long-running
3829
+ // server work. Once success headers arrive, however, every finite JSON
3830
+ // or request-scoped SSE body gets a fresh absolute deadline that server
3831
+ // config may tighten but never disable or raise.
3832
+ if (request) {
3833
+ responseTimer = setTimeout(() => {
3834
+ if (request.abortKind) return;
3835
+ request.abortKind = "response_timeout";
3836
+ request.controller.abort();
3837
+ this._sendRequestCancellation(
3838
+ serverName,
3839
+ method,
3840
+ id,
3841
+ responseTimeoutMessage,
3842
+ );
3843
+ }, responseTimeoutMs);
3844
+ responseTimer.unref?.();
3845
+ }
3846
+
2150
3847
  const contentType = response.headers?.get
2151
3848
  ? String(response.headers.get("content-type") || "").toLowerCase()
2152
3849
  : "";
2153
- // Bound the response body the same way the stdio path bounds its line
2154
- // buffer (per-server maxBufferChars override; 0 disables).
2155
- const cap = Number.isFinite(entry.config?.maxBufferChars)
2156
- ? entry.config.maxBufferChars
2157
- : MCP_MAX_BUFFER_CHARS;
2158
-
2159
3850
  let envelope;
2160
3851
  if (contentType.includes("text/event-stream")) {
2161
- envelope = await _extractSseResponse(response, id, cap, (message) =>
2162
- this._handleMessage(serverName, message),
3852
+ envelope = await _extractSseResponse(
3853
+ response,
3854
+ id,
3855
+ cap,
3856
+ (message) => this._handleMessage(serverName, message),
3857
+ controller?.signal,
3858
+ (text) => parseMcpJson(text, serverName, entry),
2163
3859
  );
2164
3860
  } else {
2165
- const text = await _readBodyCapped(response, cap);
2166
- envelope = text ? JSON.parse(text) : null;
3861
+ const text = await _readBodyCapped(response, cap, controller?.signal);
3862
+ try {
3863
+ envelope = text ? parseMcpJson(text, serverName, entry) : null;
3864
+ } catch (error) {
3865
+ if (isMcpJsonBudgetError(error)) throw error;
3866
+ // Modern JSON.parse diagnostics quote a slice of the malformed input.
3867
+ // A peer can place secrets or retry/auth keywords there, so replace
3868
+ // the SyntaxError without retaining it as a cause.
3869
+ throw mcpRpcInvalidResponseError();
3870
+ }
2167
3871
  }
2168
3872
 
2169
- if (!envelope || typeof envelope !== "object") {
2170
- throw new Error("Empty or invalid JSON-RPC response");
3873
+ const wire = snapshotRpcMessage(envelope);
3874
+ if (
3875
+ !wire ||
3876
+ !wire.hasJsonrpc ||
3877
+ wire.jsonrpc !== "2.0" ||
3878
+ !wire.hasId ||
3879
+ wire.id !== id ||
3880
+ wire.hasMethod ||
3881
+ wire.hasResult === wire.hasError
3882
+ ) {
3883
+ throw mcpRpcInvalidResponseError();
2171
3884
  }
2172
- if (envelope.error) {
2173
- throw mcpRpcError(envelope.error);
3885
+ if (wire.hasError) {
3886
+ throw mcpRpcError(wire.error);
2174
3887
  }
2175
- return envelope.result;
3888
+ throwIfAborted();
3889
+ return wire.result;
2176
3890
  } catch (err) {
2177
- if (controller && controller.signal.aborted) {
2178
- throw new Error(timeoutMessage);
2179
- }
3891
+ throwIfAborted();
2180
3892
  throw err;
2181
3893
  } finally {
2182
3894
  if (timer) clearTimeout(timer);
3895
+ if (responseTimer) clearTimeout(responseTimer);
3896
+ try {
3897
+ removeCallerAbort?.();
3898
+ } catch {
3899
+ // Best-effort listener cleanup for non-native signal adapters.
3900
+ }
3901
+ if (request) {
3902
+ request.response = null;
3903
+ requests.delete(request);
3904
+ }
2183
3905
  }
2184
3906
  }
2185
3907
 
2186
3908
  /** Fire-and-forget JSON-RPC notification over HTTP. Errors swallowed. */
2187
3909
  _sendHttpNotification(serverName, method, params) {
2188
3910
  const entry = this.servers.get(serverName);
2189
- if (!entry || !entry.httpUrl) return;
3911
+ if (!entry || !entry.httpUrl || entry._httpDiscardStopping) return;
2190
3912
  const body = JSON.stringify({ jsonrpc: "2.0", method, params });
2191
3913
  const headers = {
2192
3914
  "Content-Type": "application/json",
@@ -2198,86 +3920,270 @@ export class MCPClient extends EventEmitter {
2198
3920
  }
2199
3921
  headers["MCP-Protocol-Version"] =
2200
3922
  entry.protocolVersion || this._protocolVersion;
2201
- try {
2202
- const p = _deps.fetch(entry.httpUrl, {
2203
- method: "POST",
2204
- headers,
2205
- body,
2206
- });
2207
- if (p && typeof p.catch === "function") p.catch(() => {});
2208
- } catch {
2209
- // ignore
2210
- }
3923
+ _fetchAndDiscardHttpResponse(entry, {
3924
+ method: "POST",
3925
+ headers,
3926
+ body,
3927
+ }).catch(() => {
3928
+ // Notifications are intentionally best-effort.
3929
+ });
2211
3930
  }
2212
3931
 
2213
3932
  _handleData(serverName, data) {
2214
3933
  const entry = this.servers.get(serverName);
2215
- if (!entry) return;
2216
-
2217
- entry._buffer += data;
3934
+ if (!entry || entry._stdioFrameError) return;
3935
+
3936
+ const cap = stdioFrameByteLimit(entry.config?.maxBufferChars);
3937
+ const text = typeof data === "string" ? data : String(data || "");
3938
+ let offset = 0;
3939
+
3940
+ // Scan before concatenation/JSON.parse so both a complete line delivered in
3941
+ // one chunk and an unterminated multi-chunk tail obey the same byte budget.
3942
+ // Newline separators are framing bytes and are not part of the JSON value.
3943
+ while (offset < text.length) {
3944
+ const newline = text.indexOf("\n", offset);
3945
+ const end = newline < 0 ? text.length : newline;
3946
+ const segment = text.slice(offset, end);
3947
+ const segmentBytes = Buffer.byteLength(segment, "utf8");
3948
+ const bufferedBytes = Number.isFinite(entry._bufferBytes)
3949
+ ? entry._bufferBytes
3950
+ : Buffer.byteLength(entry._buffer || "", "utf8");
3951
+
3952
+ if (bufferedBytes + segmentBytes > cap) {
3953
+ this._failStdioFrameTooLarge(serverName, entry, cap);
3954
+ return;
3955
+ }
2218
3956
 
2219
- // Process complete JSON lines
2220
- const lines = entry._buffer.split("\n");
2221
- entry._buffer = lines.pop() || "";
3957
+ if (segment) entry._buffer += segment;
3958
+ entry._bufferBytes = bufferedBytes + segmentBytes;
3959
+ if (newline < 0) return;
2222
3960
 
2223
- for (const line of lines) {
3961
+ const line = entry._buffer;
3962
+ const frameBytes = entry._bufferBytes;
3963
+ entry._buffer = "";
3964
+ entry._bufferBytes = 0;
3965
+ offset = newline + 1;
2224
3966
  const trimmed = line.trim();
2225
3967
  if (!trimmed) continue;
2226
3968
 
3969
+ const inboundError = recordMcpInboundTraffic(
3970
+ serverName,
3971
+ entry,
3972
+ frameBytes,
3973
+ );
3974
+ if (inboundError) {
3975
+ this._failStdioInput(serverName, entry, inboundError);
3976
+ return;
3977
+ }
3978
+
3979
+ let msg;
2227
3980
  try {
2228
- const msg = JSON.parse(trimmed);
2229
- this._handleMessage(serverName, msg);
2230
- } catch {
2231
- // Skip malformed lines
3981
+ msg = parseMcpJson(trimmed, serverName, entry);
3982
+ } catch (error) {
3983
+ if (isMcpJsonBudgetError(error)) {
3984
+ this._failStdioInput(serverName, entry, error);
3985
+ return;
3986
+ }
3987
+ if (this._recordMalformedStdioFrame(serverName, entry, frameBytes)) {
3988
+ return;
3989
+ }
3990
+ continue;
2232
3991
  }
3992
+ this._handleMessage(serverName, msg);
2233
3993
  }
3994
+ }
2234
3995
 
2235
- // Guard against unbounded buffer growth: a runaway / non-MCP server that
2236
- // streams without ever sending a newline would grow the unterminated tail
2237
- // forever and exhaust memory. If the leftover partial line exceeds the cap,
2238
- // treat it as a fatal transport error (drop the buffer, drain in-flight
2239
- // requests, kill the process) rather than letting it grow without limit.
2240
- const cap = Number.isFinite(entry.config?.maxBufferChars)
2241
- ? entry.config.maxBufferChars
2242
- : MCP_MAX_BUFFER_CHARS;
2243
- if (cap > 0 && entry._buffer.length > cap) {
2244
- entry._buffer = "";
2245
- entry.state = ServerState.ERROR;
2246
- const errMsg = `MCP server "${serverName}" exceeded the ${cap}-char line buffer with no newline (runaway or non-MCP output)`;
2247
- for (const [, pending] of entry._pending) {
3996
+ _handleStdioStderr(serverName, data) {
3997
+ const entry = this.servers.get(serverName);
3998
+ if (!entry || entry._stdioFrameError) return;
3999
+ const chunkBytes =
4000
+ typeof data === "string"
4001
+ ? Buffer.byteLength(data, "utf8")
4002
+ : Number.isFinite(data?.byteLength)
4003
+ ? data.byteLength
4004
+ : Buffer.byteLength(String(data || ""), "utf8");
4005
+ if (chunkBytes <= 0) return;
4006
+ const totalBytes = (entry._stderrBytes || 0) + chunkBytes;
4007
+ entry._stderrBytes = totalBytes;
4008
+ if (totalBytes > MCP_STDIO_STDERR_MAX_BYTES) {
4009
+ const dispatched =
4010
+ entry._pending instanceof Map && entry._pending.size > 0;
4011
+ return this._failStdioInput(
4012
+ serverName,
4013
+ entry,
4014
+ mcpTransportError(
4015
+ "CC_MCP_STDIO_STDERR_TOO_LARGE",
4016
+ `MCP stdio server "${serverName}" exceeded the ${MCP_STDIO_STDERR_MAX_BYTES}-byte host stderr budget`,
4017
+ {
4018
+ transport: "stdio",
4019
+ limitBytes: MCP_STDIO_STDERR_MAX_BYTES,
4020
+ dispatched,
4021
+ outcomeUnknown: dispatched,
4022
+ },
4023
+ ),
4024
+ );
4025
+ }
4026
+ if (!entry._stderrNotified) {
4027
+ entry._stderrNotified = true;
4028
+ this.emit("server-error", {
4029
+ name: serverName,
4030
+ code: "CC_MCP_STDIO_STDERR_OUTPUT",
4031
+ error: `MCP stdio server "${serverName}" wrote diagnostics to stderr`,
4032
+ bytes: chunkBytes,
4033
+ });
4034
+ }
4035
+ return null;
4036
+ }
4037
+
4038
+ _recordMalformedStdioFrame(serverName, entry, frameBytes) {
4039
+ entry._malformedFrameCount = (entry._malformedFrameCount || 0) + 1;
4040
+ entry._malformedFrameBytes = (entry._malformedFrameBytes || 0) + frameBytes;
4041
+ if (
4042
+ entry._malformedFrameCount <= MCP_STDIO_MALFORMED_MAX_FRAMES &&
4043
+ entry._malformedFrameBytes <= MCP_STDIO_MALFORMED_MAX_BYTES
4044
+ ) {
4045
+ return false;
4046
+ }
4047
+ const dispatched = entry._pending instanceof Map && entry._pending.size > 0;
4048
+ this._failStdioInput(
4049
+ serverName,
4050
+ entry,
4051
+ mcpTransportError(
4052
+ "CC_MCP_STDIO_MALFORMED_BUDGET_EXCEEDED",
4053
+ `MCP stdio server "${serverName}" exceeded the malformed stdout budget`,
4054
+ {
4055
+ transport: "stdio",
4056
+ limitBytes: MCP_STDIO_MALFORMED_MAX_BYTES,
4057
+ limitFrames: MCP_STDIO_MALFORMED_MAX_FRAMES,
4058
+ dispatched,
4059
+ outcomeUnknown: dispatched,
4060
+ },
4061
+ ),
4062
+ );
4063
+ return true;
4064
+ }
4065
+
4066
+ _failStdioFrameTooLarge(serverName, entry, cap) {
4067
+ if (entry._stdioFrameError) return entry._stdioFrameError;
4068
+ const dispatched = entry._pending instanceof Map && entry._pending.size > 0;
4069
+ const error = mcpTransportError(
4070
+ "CC_MCP_STDIO_FRAME_TOO_LARGE",
4071
+ `MCP stdio server "${serverName}" exceeded the ${cap}-byte host line frame cap`,
4072
+ {
4073
+ transport: "stdio",
4074
+ limitBytes: cap,
4075
+ dispatched,
4076
+ outcomeUnknown: dispatched,
4077
+ },
4078
+ );
4079
+ return this._failStdioInput(serverName, entry, error);
4080
+ }
4081
+
4082
+ _failStdioInput(serverName, entry, error) {
4083
+ if (entry._stdioFrameError) return entry._stdioFrameError;
4084
+ entry._stdioFrameError = error;
4085
+ entry._buffer = "";
4086
+ entry._bufferBytes = 0;
4087
+ entry.state = ServerState.ERROR;
4088
+ if (entry._pending instanceof Map) {
4089
+ for (const pending of entry._pending.values()) {
2248
4090
  if (pending.timeout) clearTimeout(pending.timeout);
2249
4091
  try {
2250
- pending.reject(new Error(errMsg));
4092
+ pending.reject(error);
2251
4093
  } catch {
2252
- // already settled — ignore
4094
+ // Already settled; the transport remains fatally closed.
2253
4095
  }
2254
4096
  }
2255
4097
  entry._pending.clear();
2256
- if (entry.process) {
2257
- try {
2258
- entry.process.kill();
2259
- } catch {
2260
- // best-effort — surfacing the error is what matters
2261
- }
2262
- }
2263
- this.emit("server-error", { name: serverName, error: errMsg });
2264
4098
  }
4099
+ this._cancelServerElicitations(serverName);
4100
+ this.emit("server-error", {
4101
+ name: serverName,
4102
+ code: error.code,
4103
+ error: error.message,
4104
+ ...(error.limitBytes != null ? { limitBytes: error.limitBytes } : {}),
4105
+ ...(error.limitFrames != null ? { limitFrames: error.limitFrames } : {}),
4106
+ ...(error.limitMessages != null
4107
+ ? { limitMessages: error.limitMessages }
4108
+ : {}),
4109
+ ...(error.limitDepth != null ? { limitDepth: error.limitDepth } : {}),
4110
+ ...(error.limitNodes != null ? { limitNodes: error.limitNodes } : {}),
4111
+ ...(error.windowMs != null ? { windowMs: error.windowMs } : {}),
4112
+ });
4113
+ try {
4114
+ entry.process?.kill?.();
4115
+ } catch {
4116
+ // Best-effort process teardown; the fixed host error is authoritative.
4117
+ }
4118
+ return error;
2265
4119
  }
2266
4120
 
2267
4121
  _handleMessage(serverName, msg) {
2268
4122
  const entry = this.servers.get(serverName);
2269
4123
  if (!entry) return;
2270
4124
 
4125
+ const failMessage = (error) => {
4126
+ entry.state = ServerState.ERROR;
4127
+ if (entry._pending instanceof Map) {
4128
+ for (const pending of entry._pending.values()) {
4129
+ clearTimeout(pending.timeout);
4130
+ pending.reject(error);
4131
+ }
4132
+ entry._pending.clear();
4133
+ }
4134
+ this.emit("server-error", {
4135
+ name: serverName,
4136
+ code: error.code,
4137
+ error: error.message,
4138
+ ...(error.limitDepth != null ? { limitDepth: error.limitDepth } : {}),
4139
+ ...(error.limitNodes != null ? { limitNodes: error.limitNodes } : {}),
4140
+ });
4141
+ return error;
4142
+ };
4143
+
4144
+ // Snapshot every top-level field before touching the pending registry.
4145
+ // Although production transports JSON.parse their input, keeping this
4146
+ // boundary descriptor-only prevents a custom adapter/getter/Proxy from
4147
+ // consuming a pending request and then throwing before it can be rejected.
4148
+ const wire = snapshotRpcMessage(msg);
4149
+ if (!wire || !wire.hasJsonrpc || wire.jsonrpc !== "2.0") {
4150
+ return failMessage(mcpRpcInvalidMessageError());
4151
+ }
4152
+
4153
+ // Production messages are checked before JSON.parse. Keep the shared
4154
+ // object boundary independently bounded for injected/custom adapters too;
4155
+ // traversal is iterative and descriptor-only, so deep graphs, accessors,
4156
+ // and Proxy values cannot consume the pending registry first.
4157
+ const graphIssue = inspectMcpJsonGraphBudget(msg, entry.config);
4158
+ if (graphIssue === "depth" || graphIssue === "nodes") {
4159
+ return failMessage(mcpJsonBudgetError(serverName, entry, graphIssue));
4160
+ }
4161
+
2271
4162
  // Response to a request
2272
- if (msg.id !== undefined && entry._pending.has(msg.id)) {
2273
- const { resolve, reject, timeout } = entry._pending.get(msg.id);
4163
+ if (
4164
+ wire.hasId &&
4165
+ !wire.hasMethod &&
4166
+ entry._pending instanceof Map &&
4167
+ entry._pending.has(wire.id)
4168
+ ) {
4169
+ const { resolve, reject, timeout } = entry._pending.get(wire.id);
2274
4170
  clearTimeout(timeout);
2275
- entry._pending.delete(msg.id);
4171
+ entry._pending.delete(wire.id);
2276
4172
 
2277
- if (msg.error) {
2278
- reject(mcpRpcError(msg.error));
4173
+ if (wire.hasResult === wire.hasError) {
4174
+ reject(mcpRpcInvalidResponseError());
4175
+ } else if (wire.hasError) {
4176
+ reject(mcpRpcError(wire.error));
2279
4177
  } else {
2280
- resolve(msg.result);
4178
+ const resultIssue = inspectMcpJsonGraphBudget(
4179
+ wire.result,
4180
+ entry.config,
4181
+ );
4182
+ if (resultIssue === "invalid") {
4183
+ reject(mcpRpcInvalidResponseError());
4184
+ } else {
4185
+ resolve(wire.result);
4186
+ }
2281
4187
  }
2282
4188
  return;
2283
4189
  }
@@ -2286,44 +4192,62 @@ export class MCPClient extends EventEmitter {
2286
4192
  // one of ours). Previously these fell into the notification branch and
2287
4193
  // never got a response, so a server calling e.g. roots/list hung until its
2288
4194
  // own timeout. Answer the ones our advertised capabilities invite.
2289
- if (msg.id !== undefined && msg.method) {
2290
- this._handleServerRequest(serverName, msg);
4195
+ if (wire.hasId && typeof wire.method === "string" && wire.method) {
4196
+ if (
4197
+ wire.hasParams &&
4198
+ inspectMcpJsonGraphBudget(wire.params, entry.config) === "invalid"
4199
+ ) {
4200
+ failMessage(mcpRpcInvalidMessageError());
4201
+ return;
4202
+ }
4203
+ this._handleServerRequest(serverName, {
4204
+ id: wire.id,
4205
+ method: wire.method,
4206
+ params: wire.params,
4207
+ });
2291
4208
  return;
2292
4209
  }
2293
4210
 
2294
4211
  // Server notification
2295
- if (msg.method) {
4212
+ if (typeof wire.method === "string" && wire.method) {
4213
+ if (
4214
+ wire.hasParams &&
4215
+ inspectMcpJsonGraphBudget(wire.params, entry.config) === "invalid"
4216
+ ) {
4217
+ failMessage(mcpRpcInvalidMessageError());
4218
+ return;
4219
+ }
2296
4220
  // tools/resources list_changed (gap 2026-07-11 MCP 生命周期): refetch
2297
4221
  // the changed list so entry.tools/entry.resources stay live —
2298
4222
  // `listTools()`, `/mcp` status and callTool routing all see the update.
2299
4223
  // (The LLM tool array of an in-flight turn is deliberately NOT mutated:
2300
4224
  // tool-search's prompt-cache stability depends on an append-only,
2301
4225
  // stable-prefix tool list.)
2302
- if (msg.method === "notifications/tools/list_changed") {
4226
+ if (wire.method === "notifications/tools/list_changed") {
2303
4227
  this._refreshServerList(serverName, "tools");
2304
- } else if (msg.method === "notifications/resources/list_changed") {
4228
+ } else if (wire.method === "notifications/resources/list_changed") {
2305
4229
  this._refreshServerList(serverName, "resources");
2306
4230
  this._refreshServerList(serverName, "resourceTemplates");
2307
- } else if (msg.method === "notifications/resources/updated") {
4231
+ } else if (wire.method === "notifications/resources/updated") {
2308
4232
  this.emit("resource-updated", {
2309
4233
  server: serverName,
2310
- uri: msg.params?.uri || null,
2311
- params: msg.params || {},
4234
+ uri: safeRpcProperty(wire.params, "uri") || null,
4235
+ params: wire.params || {},
2312
4236
  });
2313
- } else if (msg.method === "notifications/message") {
4237
+ } else if (wire.method === "notifications/message") {
2314
4238
  this.emit("log-message", {
2315
4239
  server: serverName,
2316
- level: msg.params?.level || "info",
2317
- logger: msg.params?.logger || null,
2318
- data: msg.params?.data,
4240
+ level: safeRpcProperty(wire.params, "level") || "info",
4241
+ logger: safeRpcProperty(wire.params, "logger") || null,
4242
+ data: safeRpcProperty(wire.params, "data"),
2319
4243
  });
2320
- } else if (msg.method === "notifications/elicitation/complete") {
2321
- this._handleElicitationComplete(serverName, msg.params || {});
4244
+ } else if (wire.method === "notifications/elicitation/complete") {
4245
+ this._handleElicitationComplete(serverName, wire.params || {});
2322
4246
  }
2323
4247
  this.emit("notification", {
2324
4248
  server: serverName,
2325
- method: msg.method,
2326
- params: msg.params,
4249
+ method: wire.method,
4250
+ params: wire.params,
2327
4251
  });
2328
4252
  }
2329
4253
  }
@@ -2361,13 +4285,37 @@ export class MCPClient extends EventEmitter {
2361
4285
  : kind === "resourceTemplates"
2362
4286
  ? result?.resourceTemplates
2363
4287
  : result?.resources) || [];
2364
- entry[kind] = list;
2365
- if (kind === "tools") entry.toolsError = null;
4288
+ if (kind === "tools") {
4289
+ this._replaceToolInventory(serverName, entry, list);
4290
+ entry.toolsError = null;
4291
+ } else {
4292
+ entry[kind] = list;
4293
+ }
2366
4294
  this.emit(`${kind}-changed`, {
2367
4295
  server: serverName,
2368
4296
  count: list.length,
2369
4297
  });
2370
- } catch {
4298
+ } catch (error) {
4299
+ if (kind === "tools" && isMcpToolMetadataError(error)) {
4300
+ entry.toolsError = error.message;
4301
+ this.emit("server-error", {
4302
+ name: serverName,
4303
+ code: error.code,
4304
+ error: error.message,
4305
+ ...(error.limitBytes != null
4306
+ ? { limitBytes: error.limitBytes }
4307
+ : {}),
4308
+ ...(error.limitDepth != null
4309
+ ? { limitDepth: error.limitDepth }
4310
+ : {}),
4311
+ ...(error.limitNodes != null
4312
+ ? { limitNodes: error.limitNodes }
4313
+ : {}),
4314
+ ...(error.limitTools != null
4315
+ ? { limitTools: error.limitTools }
4316
+ : {}),
4317
+ });
4318
+ }
2371
4319
  break; // keep the previous list; the next notification retries
2372
4320
  }
2373
4321
  } while (flags[`${kind}Dirty`]);
@@ -2412,7 +4360,7 @@ export class MCPClient extends EventEmitter {
2412
4360
  /** Write a JSON-RPC response back over stdio or Streamable HTTP POST. */
2413
4361
  _sendResponse(serverName, id, result, error) {
2414
4362
  const entry = this.servers.get(serverName);
2415
- if (!entry) return;
4363
+ if (!entry || entry._httpDiscardStopping) return;
2416
4364
  const envelope =
2417
4365
  error !== undefined
2418
4366
  ? { jsonrpc: "2.0", id, error }
@@ -2427,24 +4375,19 @@ export class MCPClient extends EventEmitter {
2427
4375
  : {}),
2428
4376
  "MCP-Protocol-Version": entry.protocolVersion || this._protocolVersion,
2429
4377
  };
2430
- try {
2431
- const pending = _deps.fetch(entry.httpUrl, {
2432
- method: "POST",
2433
- headers,
2434
- body: JSON.stringify(envelope),
2435
- });
2436
- pending?.catch?.((cause) =>
2437
- this.emit("server-stream-error", {
2438
- name: serverName,
2439
- error: cause?.message || String(cause),
2440
- }),
2441
- );
2442
- } catch (cause) {
4378
+ _fetchAndDiscardHttpResponse(entry, {
4379
+ method: "POST",
4380
+ headers,
4381
+ body: JSON.stringify(envelope),
4382
+ }).catch((cause) => {
4383
+ if (entry._httpDiscardStopping) return;
4384
+ const code = safeRpcProperty(cause, "code");
2443
4385
  this.emit("server-stream-error", {
2444
4386
  name: serverName,
2445
- error: cause?.message || String(cause),
4387
+ error: safeRpcErrorText(cause) || "MCP HTTP server response failed",
4388
+ ...(code ? { code } : {}),
2446
4389
  });
2447
- }
4390
+ });
2448
4391
  return;
2449
4392
  }
2450
4393
  if (entry.socket) {
@@ -2463,27 +4406,251 @@ export class MCPClient extends EventEmitter {
2463
4406
  }
2464
4407
  }
2465
4408
 
4409
+ function _httpDiscardTimeout(configuredTimeout) {
4410
+ if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) {
4411
+ return Math.min(
4412
+ MCP_HTTP_DISCARD_TIMEOUT_MS,
4413
+ Math.max(1, Math.floor(configuredTimeout)),
4414
+ );
4415
+ }
4416
+ return MCP_HTTP_DISCARD_TIMEOUT_MS;
4417
+ }
4418
+
4419
+ function _httpResponseReadTimeout(configuredTimeout) {
4420
+ if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) {
4421
+ return Math.min(
4422
+ MCP_HTTP_RESPONSE_READ_TIMEOUT_MS,
4423
+ Math.max(1, Math.floor(configuredTimeout)),
4424
+ );
4425
+ }
4426
+ return MCP_HTTP_RESPONSE_READ_TIMEOUT_MS;
4427
+ }
4428
+
4429
+ function _sseEventTimeout(configuredTimeout) {
4430
+ if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) {
4431
+ return Math.min(
4432
+ MCP_SSE_EVENT_TIMEOUT_MS,
4433
+ Math.max(1, Math.floor(configuredTimeout)),
4434
+ );
4435
+ }
4436
+ return MCP_SSE_EVENT_TIMEOUT_MS;
4437
+ }
4438
+
4439
+ function _httpDiscardTimeoutError(entry, timeoutMs) {
4440
+ return mcpTransportError(
4441
+ "CC_MCP_HTTP_DISCARD_TIMEOUT",
4442
+ `MCP HTTP discard request exceeded the ${timeoutMs}ms host deadline`,
4443
+ { transport: entry?.transportKind, url: entry?.config?.url },
4444
+ );
4445
+ }
4446
+
4447
+ function _abortHttpRequests(entry) {
4448
+ const requests = entry?._httpRequestControllers;
4449
+ if (!requests) return;
4450
+ for (const request of requests) {
4451
+ // First abort cause wins: a timeout/caller cancellation racing disconnect
4452
+ // must retain its original public classification.
4453
+ if (!request.abortKind) request.abortKind = "disconnect";
4454
+ const response = request.response;
4455
+ request.response = null;
4456
+ _cancelHttpResponseBody(response);
4457
+ try {
4458
+ if (!request.controller.signal.aborted) request.controller.abort();
4459
+ } catch {
4460
+ // The local abort classification still fences every later await/result.
4461
+ }
4462
+ }
4463
+ // Production fetch settles on abort and its finally removes the record. The
4464
+ // eager clear also prevents a non-standard adapter that ignores signal from
4465
+ // pinning the host registry after disconnect.
4466
+ requests.clear();
4467
+ }
4468
+
4469
+ function _abortHttpDiscardRequests(entry) {
4470
+ const controllers = entry?._httpDiscardControllers;
4471
+ if (!controllers) return;
4472
+ for (const controller of controllers) {
4473
+ try {
4474
+ controller.abort();
4475
+ } catch {
4476
+ // Best-effort teardown; every request also owns a bounded timer.
4477
+ }
4478
+ }
4479
+ controllers.clear();
4480
+ }
4481
+
4482
+ async function _fetchAndDiscardHttpResponse(entry, options) {
4483
+ const controllers = entry?._httpDiscardControllers;
4484
+ const controller =
4485
+ typeof AbortController === "function" ? new AbortController() : null;
4486
+ const timeoutMs = _httpDiscardTimeout(entry?.config?.requestTimeoutMs);
4487
+ let timer = null;
4488
+ let timedOut = false;
4489
+ if (controller) {
4490
+ controllers?.add(controller);
4491
+ timer = setTimeout(() => {
4492
+ timedOut = true;
4493
+ controller.abort();
4494
+ // Production fetch rejects on abort and reaches finally. Delete here as
4495
+ // well so a non-standard adapter that ignores AbortSignal cannot pin the
4496
+ // host's in-flight controller registry past the deadline.
4497
+ controllers?.delete(controller);
4498
+ }, timeoutMs);
4499
+ timer.unref?.();
4500
+ }
4501
+ try {
4502
+ const response = await _deps.fetch(entry.httpUrl, {
4503
+ ...options,
4504
+ ...(controller ? { signal: controller.signal } : {}),
4505
+ });
4506
+ _cancelHttpResponseBody(response);
4507
+ if (timedOut) {
4508
+ throw _httpDiscardTimeoutError(entry, timeoutMs);
4509
+ }
4510
+ return response;
4511
+ } catch (cause) {
4512
+ if (
4513
+ timedOut &&
4514
+ safeRpcProperty(cause, "code") !== "CC_MCP_HTTP_DISCARD_TIMEOUT"
4515
+ ) {
4516
+ throw _httpDiscardTimeoutError(entry, timeoutMs);
4517
+ }
4518
+ throw cause;
4519
+ } finally {
4520
+ if (timer) clearTimeout(timer);
4521
+ if (controller) controllers?.delete(controller);
4522
+ }
4523
+ }
4524
+
4525
+ function _httpResponseByteLimit(configuredLimit) {
4526
+ if (Number.isFinite(configuredLimit) && configuredLimit > 0) {
4527
+ return Math.min(
4528
+ MCP_HTTP_RESPONSE_MAX_BYTES,
4529
+ Math.max(1, Math.floor(configuredLimit)),
4530
+ );
4531
+ }
4532
+ return MCP_HTTP_RESPONSE_MAX_BYTES;
4533
+ }
4534
+
4535
+ function _cancelHttpResponseBody(response) {
4536
+ try {
4537
+ const pending = response?.body?.cancel?.();
4538
+ pending?.catch?.(() => {});
4539
+ } catch {
4540
+ // Best-effort cleanup; callers still surface the bounded status/size error.
4541
+ }
4542
+ }
4543
+
4544
+ function _httpResponseTooLarge(cap, detail) {
4545
+ const error = new Error(
4546
+ `MCP HTTP response exceeded the ${cap}-byte cap${detail ? ` (${detail})` : ""}`,
4547
+ );
4548
+ error.code = "CC_MCP_HTTP_RESPONSE_TOO_LARGE";
4549
+ error.limitBytes = cap;
4550
+ return error;
4551
+ }
4552
+
4553
+ function _httpResponseStreamRequired(response) {
4554
+ _cancelHttpResponseBody(response);
4555
+ const error = new Error(
4556
+ "MCP HTTP response does not expose a readable byte stream",
4557
+ );
4558
+ error.code = "CC_MCP_HTTP_RESPONSE_STREAM_REQUIRED";
4559
+ return error;
4560
+ }
4561
+
4562
+ function _httpReadAbortedError() {
4563
+ const error = new Error("MCP HTTP response read aborted");
4564
+ error.code = "CC_MCP_HTTP_READ_ABORTED";
4565
+ return error;
4566
+ }
4567
+
4568
+ function _readHttpReaderChunk(reader, signal) {
4569
+ if (!signal?.addEventListener) return reader.read();
4570
+ if (signal.aborted) return Promise.reject(_httpReadAbortedError());
4571
+ return new Promise((resolve, reject) => {
4572
+ let settled = false;
4573
+ const finish = (settle, value) => {
4574
+ if (settled) return;
4575
+ settled = true;
4576
+ signal.removeEventListener?.("abort", onAbort);
4577
+ settle(value);
4578
+ };
4579
+ const onAbort = () => finish(reject, _httpReadAbortedError());
4580
+ signal.addEventListener("abort", onAbort, { once: true });
4581
+ Promise.resolve()
4582
+ .then(() => reader.read())
4583
+ .then(
4584
+ (result) => finish(resolve, result),
4585
+ (error) => finish(reject, error),
4586
+ );
4587
+ // Cover a custom signal that changes between the preflight read and
4588
+ // listener installation.
4589
+ if (signal.aborted) onAbort();
4590
+ });
4591
+ }
4592
+
4593
+ function _waitForHttpMessageStreamResponse(responsePromise, signal) {
4594
+ if (!signal?.addEventListener) return Promise.resolve(responsePromise);
4595
+ if (signal.aborted) {
4596
+ Promise.resolve(responsePromise).then(
4597
+ (response) => _cancelHttpResponseBody(response),
4598
+ () => {},
4599
+ );
4600
+ return Promise.reject(_httpReadAbortedError());
4601
+ }
4602
+ return new Promise((resolve, reject) => {
4603
+ let settled = false;
4604
+ let aborted = false;
4605
+ const cleanup = () => signal.removeEventListener?.("abort", onAbort);
4606
+ const finish = (settler, value) => {
4607
+ if (settled) return false;
4608
+ settled = true;
4609
+ cleanup();
4610
+ settler(value);
4611
+ return true;
4612
+ };
4613
+ const onAbort = () => {
4614
+ aborted = true;
4615
+ finish(reject, _httpReadAbortedError());
4616
+ };
4617
+ signal.addEventListener("abort", onAbort, { once: true });
4618
+ Promise.resolve(responsePromise).then(
4619
+ (response) => {
4620
+ if (!finish(resolve, response) && aborted) {
4621
+ _cancelHttpResponseBody(response);
4622
+ }
4623
+ },
4624
+ (error) => finish(reject, error),
4625
+ );
4626
+ // Cover a custom signal that changes between preflight and installation.
4627
+ if (signal.aborted) onAbort();
4628
+ });
4629
+ }
4630
+
2466
4631
  /**
2467
- * Read an HTTP response body to text with a hard size cap, mirroring the stdio
2468
- * transport's MCP_MAX_BUFFER_CHARS guard. The per-call timeout bounds TIME but
2469
- * not MEMORY: a malicious/buggy HTTP MCP server could stream a multi-GB body
2470
- * within the timeout and OOM the client. When the body is a readable stream
2471
- * (real fetch) we accumulate with a running cap and cancel on overflow; for a
2472
- * non-stream response (test mocks) we fall back to text() + a post-hoc check.
2473
- * A declared Content-Length over the cap is rejected before any read. cap<=0
2474
- * disables the limit.
4632
+ * Read a finite HTTP response body to text under a host-owned absolute byte
4633
+ * ceiling. The per-call timeout bounds TIME but not MEMORY: a malicious or
4634
+ * buggy MCP server could otherwise stream a multi-GB response body within the
4635
+ * timeout and OOM the client. Fetch responses are accumulated
4636
+ * only after a running byte check and their reader is cancelled on overflow.
4637
+ * A non-standard Response-like adapter without a byte reader is rejected
4638
+ * before text() because an after-the-fact string check cannot bound allocation.
4639
+ * A declared Content-Length over the effective cap is rejected before any read.
2475
4640
  */
2476
- async function _readBodyCapped(response, cap) {
2477
- if (
2478
- cap > 0 &&
2479
- response.headers &&
2480
- typeof response.headers.get === "function"
2481
- ) {
2482
- const declared = Number(response.headers.get("content-length"));
2483
- if (Number.isFinite(declared) && declared > cap) {
2484
- throw new Error(
2485
- `MCP HTTP response exceeds the ${cap}-byte cap (content-length ${declared})`,
2486
- );
4641
+ async function _readBodyCapped(response, cap, signal = null) {
4642
+ cap = _httpResponseByteLimit(cap);
4643
+ if (response.headers && typeof response.headers.get === "function") {
4644
+ const declaredHeader = response.headers.get("content-length");
4645
+ if (/^\d+$/.test(String(declaredHeader || "").trim())) {
4646
+ const declared = BigInt(String(declaredHeader).trim());
4647
+ if (declared > BigInt(cap)) {
4648
+ _cancelHttpResponseBody(response);
4649
+ throw _httpResponseTooLarge(
4650
+ cap,
4651
+ `content-length ${String(declaredHeader).trim()}`,
4652
+ );
4653
+ }
2487
4654
  }
2488
4655
  }
2489
4656
  if (response.body && typeof response.body.getReader === "function") {
@@ -2491,33 +4658,39 @@ async function _readBodyCapped(response, cap) {
2491
4658
  const decoder = new TextDecoder("utf-8");
2492
4659
  const chunks = [];
2493
4660
  let total = 0;
2494
- for (;;) {
2495
- const { value, done } = await reader.read();
2496
- if (done) break;
2497
- total += value && value.length ? value.length : 0;
2498
- if (cap > 0 && total > cap) {
2499
- try {
2500
- await reader.cancel();
2501
- } catch {
2502
- /* best-effort — the throw below is what matters */
4661
+ try {
4662
+ for (;;) {
4663
+ const { value, done } = await _readHttpReaderChunk(reader, signal);
4664
+ if (done) break;
4665
+ total += value?.byteLength || 0;
4666
+ if (total > cap) {
4667
+ try {
4668
+ await reader.cancel();
4669
+ } catch {
4670
+ /* best-effort — the throw below is what matters */
4671
+ }
4672
+ throw _httpResponseTooLarge(cap, "runaway server");
2503
4673
  }
2504
- throw new Error(
2505
- `MCP HTTP response exceeded the ${cap}-byte cap (runaway server)`,
2506
- );
4674
+ chunks.push(decoder.decode(value, { stream: true }));
4675
+ }
4676
+ chunks.push(decoder.decode());
4677
+ return chunks.join("");
4678
+ } catch (error) {
4679
+ try {
4680
+ await reader.cancel();
4681
+ } catch {
4682
+ // Best-effort cleanup; preserve the original read/limit error.
4683
+ }
4684
+ throw error;
4685
+ } finally {
4686
+ try {
4687
+ reader.releaseLock?.();
4688
+ } catch {
4689
+ // The reader may already be detached after cancellation.
2507
4690
  }
2508
- chunks.push(decoder.decode(value, { stream: true }));
2509
4691
  }
2510
- chunks.push(decoder.decode());
2511
- return chunks.join("");
2512
4692
  }
2513
- if (typeof response.text !== "function") {
2514
- throw new Error("HTTP response is not readable");
2515
- }
2516
- const text = await response.text();
2517
- if (cap > 0 && text.length > cap) {
2518
- throw new Error(`MCP HTTP response exceeded the ${cap}-char cap`);
2519
- }
2520
- return text;
4693
+ throw _httpResponseStreamRequired(response);
2521
4694
  }
2522
4695
 
2523
4696
  /**
@@ -2531,13 +4704,15 @@ async function _extractSseResponse(
2531
4704
  requestId,
2532
4705
  cap = 0,
2533
4706
  onMessage = null,
4707
+ signal = null,
4708
+ parseJson = JSON.parse,
2534
4709
  ) {
2535
- const text = await _readBodyCapped(response, cap);
4710
+ const text = await _readBodyCapped(response, cap, signal);
2536
4711
 
2537
4712
  // Split into events on blank line, parse each event's concatenated `data:` lines.
2538
4713
  const events = text.split(/\r?\n\r?\n/);
2539
4714
  for (const event of events) {
2540
- const parsed = _parseSseEvent(event);
4715
+ const parsed = _parseSseEvent(event, parseJson);
2541
4716
  if (!parsed?.message) continue;
2542
4717
  const payload = parsed.message;
2543
4718
  if (payload.jsonrpc === "2.0" && payload.id === requestId) {
@@ -2548,7 +4723,7 @@ async function _extractSseResponse(
2548
4723
  throw new Error(`SSE stream ended without a response for id ${requestId}`);
2549
4724
  }
2550
4725
 
2551
- function _parseSseEvent(event) {
4726
+ function _parseSseEvent(event, parseJson = JSON.parse) {
2552
4727
  const dataLines = [];
2553
4728
  let id = null;
2554
4729
  let retry = null;
@@ -2566,9 +4741,10 @@ function _parseSseEvent(event) {
2566
4741
  let message = null;
2567
4742
  if (dataLines.some((line) => line.length > 0)) {
2568
4743
  try {
2569
- const candidate = JSON.parse(dataLines.join("\n"));
4744
+ const candidate = parseJson(dataLines.join("\n"));
2570
4745
  if (candidate && typeof candidate === "object") message = candidate;
2571
- } catch {
4746
+ } catch (error) {
4747
+ if (isMcpJsonBudgetError(error)) throw error;
2572
4748
  // Non-JSON SSE events are transport metadata, not MCP messages.
2573
4749
  }
2574
4750
  }
@@ -2577,10 +4753,24 @@ function _parseSseEvent(event) {
2577
4753
 
2578
4754
  async function _consumeSseMessageStream(
2579
4755
  response,
2580
- { cap = 0, stream, onMessage },
4756
+ {
4757
+ cap = 0,
4758
+ stream,
4759
+ eventTimeoutMs = MCP_SSE_EVENT_TIMEOUT_MS,
4760
+ onInbound = null,
4761
+ parseJson = JSON.parse,
4762
+ onMessage,
4763
+ },
2581
4764
  ) {
4765
+ cap = _httpResponseByteLimit(cap);
4766
+ eventTimeoutMs = _sseEventTimeout(eventTimeoutMs);
2582
4767
  const dispatch = (rawEvent) => {
2583
- const parsed = _parseSseEvent(rawEvent);
4768
+ const eventBytes = Buffer.byteLength(String(rawEvent || ""), "utf8");
4769
+ if (eventBytes > cap) {
4770
+ throw _httpResponseTooLarge(cap, "SSE event");
4771
+ }
4772
+ onInbound?.(eventBytes);
4773
+ const parsed = _parseSseEvent(rawEvent, parseJson);
2584
4774
  if (parsed.id != null && parsed.id !== "") {
2585
4775
  stream.lastEventId = parsed.id;
2586
4776
  }
@@ -2589,7 +4779,11 @@ async function _consumeSseMessageStream(
2589
4779
  };
2590
4780
 
2591
4781
  if (!response.body || typeof response.body.getReader !== "function") {
2592
- const text = await _readBodyCapped(response, cap);
4782
+ const text = await _readBodyCapped(
4783
+ response,
4784
+ cap,
4785
+ stream.controller?.signal,
4786
+ );
2593
4787
  for (const event of text.split(/\r?\n\r?\n/)) dispatch(event);
2594
4788
  return;
2595
4789
  }
@@ -2597,14 +4791,88 @@ async function _consumeSseMessageStream(
2597
4791
  const reader = response.body.getReader();
2598
4792
  const decoder = new TextDecoder("utf-8");
2599
4793
  let buffer = "";
4794
+ let wireEventBytes = 0;
4795
+ let wireTail = [];
4796
+ let eventTimer = null;
4797
+ let eventTimedOut = false;
4798
+ const separators = [
4799
+ [13, 10, 13, 10],
4800
+ [13, 10, 10],
4801
+ [10, 13, 10],
4802
+ [10, 10],
4803
+ ];
4804
+ const endsWithBytes = (source, suffix) =>
4805
+ suffix.length <= source.length &&
4806
+ suffix.every(
4807
+ (byte, index) => source[source.length - suffix.length + index] === byte,
4808
+ );
4809
+ const delimiterPrefixLength = () => {
4810
+ let longest = 0;
4811
+ for (const separator of separators) {
4812
+ for (let length = 1; length < separator.length; length += 1) {
4813
+ if (
4814
+ length > longest &&
4815
+ endsWithBytes(wireTail, separator.slice(0, length))
4816
+ ) {
4817
+ longest = length;
4818
+ }
4819
+ }
4820
+ }
4821
+ return longest;
4822
+ };
4823
+ const scanEventBytes = (value) => {
4824
+ let sawBoundary = false;
4825
+ const bytes = value
4826
+ ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
4827
+ : [];
4828
+ for (const byte of bytes) {
4829
+ wireEventBytes += 1;
4830
+ wireTail.push(byte);
4831
+ if (wireTail.length > 4) wireTail.shift();
4832
+ const separator = separators.find((candidate) =>
4833
+ endsWithBytes(wireTail, candidate),
4834
+ );
4835
+ if (separator) {
4836
+ const payloadBytes = wireEventBytes - separator.length;
4837
+ if (payloadBytes > cap) {
4838
+ throw _httpResponseTooLarge(cap, "SSE event");
4839
+ }
4840
+ wireEventBytes = 0;
4841
+ wireTail = [];
4842
+ sawBoundary = true;
4843
+ } else if (wireEventBytes - delimiterPrefixLength() > cap) {
4844
+ throw _httpResponseTooLarge(cap, "unterminated SSE event");
4845
+ }
4846
+ }
4847
+ return sawBoundary;
4848
+ };
4849
+ const clearEventTimer = () => {
4850
+ if (eventTimer) clearTimeout(eventTimer);
4851
+ eventTimer = null;
4852
+ };
4853
+ const armEventTimer = () => {
4854
+ if (wireEventBytes <= 0 || eventTimer) return;
4855
+ eventTimer = setTimeout(() => {
4856
+ eventTimedOut = true;
4857
+ try {
4858
+ stream.controller?.abort();
4859
+ } catch {
4860
+ // The local timeout classification remains authoritative.
4861
+ }
4862
+ }, eventTimeoutMs);
4863
+ eventTimer.unref?.();
4864
+ };
2600
4865
  try {
2601
4866
  for (;;) {
2602
- const { value, done } = await reader.read();
4867
+ const { value, done } = await _readHttpReaderChunk(
4868
+ reader,
4869
+ stream.controller?.signal,
4870
+ );
2603
4871
  if (done) break;
4872
+ const sawBoundary = scanEventBytes(value);
4873
+ if (sawBoundary) clearEventTimer();
4874
+ armEventTimer();
2604
4875
  buffer += decoder.decode(value, { stream: true });
2605
- if (cap > 0 && buffer.length > cap) {
2606
- throw new Error(`MCP HTTP SSE event exceeded the ${cap}-char cap`);
2607
- }
2608
4876
  let boundary;
2609
4877
  while ((boundary = buffer.search(/\r?\n\r?\n/)) >= 0) {
2610
4878
  const separator = buffer.match(/\r?\n\r?\n/)[0];
@@ -2614,8 +4882,21 @@ async function _consumeSseMessageStream(
2614
4882
  }
2615
4883
  }
2616
4884
  buffer += decoder.decode();
4885
+ if (wireEventBytes > cap) {
4886
+ throw _httpResponseTooLarge(cap, "unterminated SSE event");
4887
+ }
2617
4888
  if (buffer.trim()) dispatch(buffer);
4889
+ } catch (error) {
4890
+ try {
4891
+ const pending = reader.cancel();
4892
+ pending?.catch?.(() => {});
4893
+ } catch {
4894
+ // Best-effort cleanup; the bounded stream error remains authoritative.
4895
+ }
4896
+ if (eventTimedOut) throw mcpSseEventTimeoutError(eventTimeoutMs);
4897
+ throw error;
2618
4898
  } finally {
4899
+ clearEventTimer();
2619
4900
  try {
2620
4901
  reader.releaseLock?.();
2621
4902
  } catch {