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