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