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