chainlesschain 0.162.197 → 0.162.198

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