@animalabs/agent-framework 0.22.3 → 0.23.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 +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -13,7 +13,8 @@ import { openTransport, STDERR_END_WAIT_MS } from './transport.js';
|
|
|
13
13
|
import { maskNegotiatedCapabilities } from './capability-mask.js';
|
|
14
14
|
import { CapabilityGrant, expandAdvertisementShorthand } from './capability-grant.js';
|
|
15
15
|
import { CAPABILITY_DISABLED, boundedForLog, describeCause } from './errors.js';
|
|
16
|
-
import { logDetail, logValue } from './log-value.js';
|
|
16
|
+
import { logDetail, logValue, logValueRedacted } from './log-value.js';
|
|
17
|
+
import { LEGACY_MCP_PROTOCOL_VERSION, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, redactServerText, } from './protocol-family.js';
|
|
17
18
|
import { McplMethod } from './types.js';
|
|
18
19
|
/** Timeout for the initialize handshake in milliseconds.
|
|
19
20
|
* Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
|
|
@@ -25,24 +26,26 @@ export function stderrTailOf(error) {
|
|
|
25
26
|
return typeof tail === 'string' && tail !== '' ? tail : undefined;
|
|
26
27
|
}
|
|
27
28
|
/** For the host's own log line about a failed connect: the server's stderr
|
|
28
|
-
* that the error keeps as `stderrTail`,
|
|
29
|
-
*
|
|
29
|
+
* that the error keeps as `stderrTail`, with the server's credentials
|
|
30
|
+
* taken out by `redact` (its redactServerText), one marked line per line of
|
|
31
|
+
* it, each through log-value, since it's the server's text. '' when there's
|
|
30
32
|
* none. */
|
|
31
|
-
export function withStderr(error) {
|
|
33
|
+
export function withStderr(error, redact) {
|
|
32
34
|
const tail = stderrTailOf(error);
|
|
33
35
|
if (tail === undefined)
|
|
34
36
|
return '';
|
|
35
|
-
return '. The last of its stderr:' + tail.split('\n').map((line) => `\n stderr: ${logDetail(line)}`).join('');
|
|
37
|
+
return '. The last of its stderr:' + redact(tail).split('\n').map((line) => `\n stderr: ${logDetail(line)}`).join('');
|
|
36
38
|
}
|
|
37
|
-
/** A transport close's reason, as
|
|
38
|
-
* own words. A WebSocket peer's close reason is the
|
|
39
|
-
* (`reasonFromPeer`), so
|
|
40
|
-
* close without one, as `mcpl:server-closed` leaves that text out. A
|
|
39
|
+
/** A transport close's reason, as the host's log and the handshake's error
|
|
40
|
+
* give it: the transport's own words. A WebSocket peer's close reason is the
|
|
41
|
+
* server's text (`reasonFromPeer`), so they give the words the transport uses
|
|
42
|
+
* for a close without one, as `mcpl:server-closed` leaves that text out. A
|
|
41
43
|
* WebSocket close's code, a number, says what the server meant (4001: a
|
|
42
|
-
* refused credential), with or without a reason, so each
|
|
43
|
-
* a
|
|
44
|
-
* WebSocket close, since a close before the
|
|
45
|
-
* 'close' event. A stdio child's exit reads
|
|
44
|
+
* refused credential), with or without a reason, so each gives it too: the
|
|
45
|
+
* log line for a close with requests pending has `code=`, and the
|
|
46
|
+
* handshake's error adds it for any WebSocket close, since a close before the
|
|
47
|
+
* handshake completes reaches no 'close' event. A stdio child's exit reads
|
|
48
|
+
* as before. */
|
|
46
49
|
function closeReasonText(info) {
|
|
47
50
|
if (info.reasonFromPeer)
|
|
48
51
|
return 'websocket closed';
|
|
@@ -54,8 +57,110 @@ function closeReasonText(info) {
|
|
|
54
57
|
* endTurn, no further wakes, zero errors anywhere. 60s is generous for real
|
|
55
58
|
* tool work while still bounding the hang. */
|
|
56
59
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
57
|
-
/** MCP protocol version used in the initialize handshake
|
|
58
|
-
|
|
60
|
+
/** MCP protocol version used in the initialize handshake: the only revision
|
|
61
|
+
* this legacy engine offers or accepts. */
|
|
62
|
+
const MCP_PROTOCOL_VERSION = LEGACY_MCP_PROTOCOL_VERSION;
|
|
63
|
+
/** JSON-RPC code for an unsupported protocol version (MCP 2026-07-28 §basic,
|
|
64
|
+
* the code a modern-only server answers our `initialize` with). */
|
|
65
|
+
const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
|
|
66
|
+
/** Upper bound on `tools/list` pages, against a server whose cursors never
|
|
67
|
+
* end. Generous: real inventories fit in a handful of pages. */
|
|
68
|
+
const MAX_TOOL_LIST_PAGES = 100;
|
|
69
|
+
/**
|
|
70
|
+
* The server and this engine have no protocol revision in common, so the
|
|
71
|
+
* connection was never established. This is a protocol-negotiation fact, not
|
|
72
|
+
* a request outcome. Retrying cannot change it: each engine offers one
|
|
73
|
+
* revision, and only configuration can fix the mismatch. A connection that
|
|
74
|
+
* gets this error does not schedule reconnects. The legacy engine and the
|
|
75
|
+
* modern one (ModernMcpConnection, whose pinned revision the server refused)
|
|
76
|
+
* both throw it, so its readers treat them alike.
|
|
77
|
+
*
|
|
78
|
+
* - `rejected`: the server refused the offered revision: the legacy engine's
|
|
79
|
+
* `initialize` answered with error -32022, or a modern server's discovery
|
|
80
|
+
* or error naming no common revision. `supported` is the server's own list,
|
|
81
|
+
* when it gave one.
|
|
82
|
+
* - `mismatch`: the server answered the legacy engine's `initialize` with a
|
|
83
|
+
* revision other than 2024-11-05, or with none.
|
|
84
|
+
*/
|
|
85
|
+
export class McplProtocolVersionError extends Error {
|
|
86
|
+
kind;
|
|
87
|
+
returned;
|
|
88
|
+
supported;
|
|
89
|
+
code;
|
|
90
|
+
data;
|
|
91
|
+
offered;
|
|
92
|
+
constructor(message, kind,
|
|
93
|
+
/** Revision the server answered with (`mismatch`; null when it gave none). */
|
|
94
|
+
returned,
|
|
95
|
+
/** Revisions the server says it supports (`rejected`, when stated). */
|
|
96
|
+
supported,
|
|
97
|
+
/** The JSON-RPC error's code and data, verbatim (`rejected`). */
|
|
98
|
+
code, data,
|
|
99
|
+
/** The revision this client offered: the legacy engine's, unless a
|
|
100
|
+
* modern connection's. */
|
|
101
|
+
offered = MCP_PROTOCOL_VERSION) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.kind = kind;
|
|
104
|
+
this.returned = returned;
|
|
105
|
+
this.supported = supported;
|
|
106
|
+
this.code = code;
|
|
107
|
+
this.data = data;
|
|
108
|
+
this.offered = offered;
|
|
109
|
+
this.name = 'McplProtocolVersionError';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function protocolVersionRejection(id, error) {
|
|
113
|
+
const data = error.data;
|
|
114
|
+
const supported = Array.isArray(data?.supported) && data.supported.every((v) => typeof v === 'string')
|
|
115
|
+
? data.supported
|
|
116
|
+
: null;
|
|
117
|
+
const modernHint = supported?.includes(MODERN_MCP_PROTOCOL_VERSION)
|
|
118
|
+
? ` It speaks modern MCP: configure it with protocol: 'modern' (stdio) or reach it over http(s).`
|
|
119
|
+
: '';
|
|
120
|
+
return new McplProtocolVersionError(`MCPL server "${id}" does not support MCP ${MCP_PROTOCOL_VERSION}` +
|
|
121
|
+
// The server's message can be any JSON value, so it's read without
|
|
122
|
+
// throwing (describeCause) and bounded, as the initialize error is.
|
|
123
|
+
(supported ? ` (it supports: ${supported.join(', ') || 'none listed'}).` : ` (${boundedForLog(describeCause(error.message))}).`) +
|
|
124
|
+
modernHint, 'rejected', null, supported, error.code, error.data);
|
|
125
|
+
}
|
|
126
|
+
const errorOf = (value) => (value instanceof Error ? value : new Error(String(value)));
|
|
127
|
+
/**
|
|
128
|
+
* A handshake that failed, and whose cleanup couldn't confirm that the child
|
|
129
|
+
* exited. The transport goes to whoever owns the launch.
|
|
130
|
+
*/
|
|
131
|
+
class HandshakeCleanupError extends Error {
|
|
132
|
+
failure;
|
|
133
|
+
cleanup;
|
|
134
|
+
transport;
|
|
135
|
+
constructor(failure, cleanup, transport) {
|
|
136
|
+
super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure });
|
|
137
|
+
this.failure = failure;
|
|
138
|
+
this.cleanup = cleanup;
|
|
139
|
+
this.transport = transport;
|
|
140
|
+
this.name = 'HandshakeCleanupError';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A connect that failed, and whose cleanup couldn't confirm that the child
|
|
145
|
+
* exited: it may still be running. `connection` owns that launch, closed and
|
|
146
|
+
* not reconnecting. Its close() retries the reap and resolves once the exit
|
|
147
|
+
* is confirmed, so the caller can still clean up, and should start nothing
|
|
148
|
+
* else for this server until then. connect() and connectWithReconnect()
|
|
149
|
+
* throw this, and the modern engine's ModernMcpConnection.connect() too.
|
|
150
|
+
* `cause` is the connect's own failure.
|
|
151
|
+
*/
|
|
152
|
+
export class McplUnreapedLaunchError extends Error {
|
|
153
|
+
connection;
|
|
154
|
+
cleanup;
|
|
155
|
+
constructor(connection, failure,
|
|
156
|
+
/** The cleanup's own failure: the child it couldn't confirm exited. */
|
|
157
|
+
cleanup) {
|
|
158
|
+
super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure });
|
|
159
|
+
this.connection = connection;
|
|
160
|
+
this.cleanup = cleanup;
|
|
161
|
+
this.name = 'McplUnreapedLaunchError';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
59
164
|
/**
|
|
60
165
|
* Why a request to an MCPL server produced no result, as far as the host can
|
|
61
166
|
* know it. Messages are unchanged from plain errors; the `outcome` makes the
|
|
@@ -110,7 +215,12 @@ function describeMethod(method) {
|
|
|
110
215
|
* server's stderr when its handshake failed by its transport closing, a
|
|
111
216
|
* transport error or its timeout and it had written any; `error` never
|
|
112
217
|
* carries it.
|
|
113
|
-
* - `'reconnect'` — Background reconnect succeeded
|
|
218
|
+
* - `'reconnect'` — Background reconnect succeeded. The new transport
|
|
219
|
+
* starts with an empty grant and both planes
|
|
220
|
+
* closed, as at initial connect: whoever owns the
|
|
221
|
+
* connection runs the §5.3 policy exchange again,
|
|
222
|
+
* then opens the planes (the framework's listener
|
|
223
|
+
* does).
|
|
114
224
|
*/
|
|
115
225
|
export class McplServerConnection extends EventEmitter {
|
|
116
226
|
/** Unique server identifier from config. */
|
|
@@ -126,6 +236,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
126
236
|
/** OUTER standard MCP capabilities.tools was present at handshake (§5.1 —
|
|
127
237
|
* the sole source of the `tools` capability path). */
|
|
128
238
|
mcpToolsAdvertised = false;
|
|
239
|
+
/** The MCP revision established by the last successful handshake (always
|
|
240
|
+
* 2024-11-05 for this engine), or null before the first one. */
|
|
241
|
+
protocolVersion = null;
|
|
129
242
|
/** Host-owned per-server authority for host/command (config
|
|
130
243
|
* allowHostCommands, default false). No capability path exists and the
|
|
131
244
|
* grant cannot confer it — server params never decide admin authority. */
|
|
@@ -180,6 +293,22 @@ export class McplServerConnection extends EventEmitter {
|
|
|
180
293
|
this.grant = CapabilityGrant.empty();
|
|
181
294
|
this.policyEstablished = false;
|
|
182
295
|
this.transportEpoch++;
|
|
296
|
+
// What the server sent that is still held came on the transport that is
|
|
297
|
+
// now gone. What it said about its own process is dropped: its requests
|
|
298
|
+
// wait on answers that can no longer reach whoever asked (an inference
|
|
299
|
+
// request, a host/command), and its announcements describe a process
|
|
300
|
+
// that is gone (a tools/observe filter, a channel registration), so the
|
|
301
|
+
// next one announces its own. What it reported about the world is kept: a
|
|
302
|
+
// channel's incoming messages and its pushes, which the next process
|
|
303
|
+
// doesn't send again when its bridge records them as forwarded first, as
|
|
304
|
+
// discord-mcpl does with its watermark. They are admitted at the flush
|
|
305
|
+
// against the next transport's grant, like anything held, and an answer
|
|
306
|
+
// to one is never written to the next transport (sameTransport); a
|
|
307
|
+
// connection closed for good, with no next transport, drops them. The
|
|
308
|
+
// host's own reports about the transport stay too: its lifecycle
|
|
309
|
+
// (connect-failed, reconnect-failed), the server's stderr, and
|
|
310
|
+
// orphaned-response audits.
|
|
311
|
+
this.replaceHeld(this.keepAcrossBoundary(this.bufferedEvents));
|
|
183
312
|
// RFC-007 §6.5: a filter is per connection epoch — after a reconnect the
|
|
184
313
|
// server sends it again, and until then receives metadata only.
|
|
185
314
|
this.toolObserveFilter = null;
|
|
@@ -189,6 +318,59 @@ export class McplServerConnection extends EventEmitter {
|
|
|
189
318
|
// (Sol, PR #84 review — they previously survived into the new epoch).
|
|
190
319
|
this.manifestState = { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null };
|
|
191
320
|
}
|
|
321
|
+
/** What of these held items a transport boundary keeps (see
|
|
322
|
+
* resetPolicyForTransportBoundary), in order, each marked `kept`; the
|
|
323
|
+
* rest is logged and dropped. A connection closed for good has no next
|
|
324
|
+
* transport whose grant could admit what the server reported about the
|
|
325
|
+
* world, so that is dropped too. */
|
|
326
|
+
keepAcrossBoundary(held) {
|
|
327
|
+
const forGood = this.closedForGood;
|
|
328
|
+
const drops = (item) => McplServerConnection.PROCESS_EVENTS.has(item.event)
|
|
329
|
+
|| (forGood && McplServerConnection.WORLD_EVENTS.has(item.event));
|
|
330
|
+
const kept = held.filter((item) => !drops(item));
|
|
331
|
+
if (kept.length < held.length) {
|
|
332
|
+
// Named by event, with a count, so a dropped request a server was
|
|
333
|
+
// waiting on, which no answer will now reach, can be told apart.
|
|
334
|
+
const counts = new Map();
|
|
335
|
+
for (const item of held)
|
|
336
|
+
if (drops(item))
|
|
337
|
+
counts.set(item.event, (counts.get(item.event) ?? 0) + 1);
|
|
338
|
+
const what = [...counts].map(([event, count]) => `${count} ${logValue(event)}`).join(', ');
|
|
339
|
+
console.error(`[mcpl] ${this.id}: dropped ${held.length - kept.length} held message(s) from a transport that closed` +
|
|
340
|
+
`${forGood ? ', with no transport to come' : ''}: ${what}`);
|
|
341
|
+
}
|
|
342
|
+
return kept.map((item) => ({ ...item, kept: true }));
|
|
343
|
+
}
|
|
344
|
+
/** Hold these items in place of what is held, with heldRequests and
|
|
345
|
+
* keptRequests counting the requests among them: a boundary keeps some of
|
|
346
|
+
* what was held, a flush that crosses one holds the rest of its snapshot
|
|
347
|
+
* again, and a flush starts from nothing held. Notifications need no cap
|
|
348
|
+
* here: hold caps them kept or not, and what this is given never has more
|
|
349
|
+
* than was held. */
|
|
350
|
+
replaceHeld(items) {
|
|
351
|
+
// The kept bound holds here too: boundaries that come with no flush
|
|
352
|
+
// between them each keep what the next transport held, so it's checked
|
|
353
|
+
// where they're joined. The earliest kept are kept, as at hold.
|
|
354
|
+
let kept = 0;
|
|
355
|
+
let past = 0;
|
|
356
|
+
this.bufferedEvents = items.filter((item) => {
|
|
357
|
+
if (item.kept !== true || !McplServerConnection.hasResponder(item))
|
|
358
|
+
return true;
|
|
359
|
+
if (kept < McplServerConnection.MAX_HELD_REQUESTS) {
|
|
360
|
+
kept++;
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
past++;
|
|
364
|
+
return false;
|
|
365
|
+
});
|
|
366
|
+
this.heldRequests = this.bufferedEvents.filter((item) => !item.kept && McplServerConnection.hasResponder(item)).length;
|
|
367
|
+
this.keptRequests = kept;
|
|
368
|
+
if (past > 0) {
|
|
369
|
+
this.droppedKeptRequests += past;
|
|
370
|
+
console.error(`[mcpl] ${this.id}: kept-request bound (${McplServerConnection.MAX_HELD_REQUESTS}) — ` +
|
|
371
|
+
`dropped ${past} request(s) a closed transport left`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
192
374
|
/** The active transport (stdio child or WebSocket). Null for a disconnected
|
|
193
375
|
* stub awaiting its first background reconnect. */
|
|
194
376
|
transport;
|
|
@@ -206,9 +388,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
206
388
|
/** Set by `reconnectAfterFailure()` before it closes a live transport, so
|
|
207
389
|
* the transport close handler reports that teardown as `byHost: true`. */
|
|
208
390
|
hostCloseRequested = false;
|
|
391
|
+
/** Set by `close()`: this host has stopped the connection, as a removal and
|
|
392
|
+
* a restart both do, so a request after it can't say whether the server
|
|
393
|
+
* comes back. */
|
|
394
|
+
closedByThisHost = false;
|
|
395
|
+
/** The host's close attempt: in flight, or done. A failed one is cleared,
|
|
396
|
+
* so the next close() tries again. */
|
|
209
397
|
closePromise = null;
|
|
398
|
+
/** The host closed this connection while it was live, and its teardown
|
|
399
|
+
* hasn't completed yet: 'close' is emitted once it has. */
|
|
210
400
|
closeIncomplete = false;
|
|
211
|
-
reconnectPromise = null;
|
|
212
401
|
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
213
402
|
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
214
403
|
/** The deadline a channels/publish or channels/open request keeps when
|
|
@@ -224,6 +413,17 @@ export class McplServerConnection extends EventEmitter {
|
|
|
224
413
|
/** How many of bufferedEvents are server→host requests, each holding a
|
|
225
414
|
* responder its server waits on. Every change to bufferedEvents keeps it. */
|
|
226
415
|
heldRequests = 0;
|
|
416
|
+
/** How many of bufferedEvents are requests a transport boundary kept:
|
|
417
|
+
* incoming messages and pushes whose transport has closed. No server waits
|
|
418
|
+
* on them, since their answers can't reach it (sameTransport), so they
|
|
419
|
+
* stay out of the held-request budget, which bounds what a live server
|
|
420
|
+
* waits on: a dead process's backlog must not refuse the next process's
|
|
421
|
+
* registration. They have their own bound at the same size, past which a
|
|
422
|
+
* kept request is dropped and logged, there being no one to refuse. */
|
|
423
|
+
keptRequests = 0;
|
|
424
|
+
/** Kept requests dropped at their bound since the planes last opened
|
|
425
|
+
* (ready()), so the first of each run of drops is logged. */
|
|
426
|
+
droppedKeptRequests = 0;
|
|
227
427
|
/** Cap on held notifications (responder-less events) during a plane pause
|
|
228
428
|
* (issue #122), counted apart from requests: past it, the oldest held
|
|
229
429
|
* notification is dropped. */
|
|
@@ -233,7 +433,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
233
433
|
* bounds a server that pipelines requests without waiting for answers. A
|
|
234
434
|
* server that doesn't send a refused request again loses it (discord-mcpl
|
|
235
435
|
* doesn't), so the budget sits far above the notifications' cap, to leave
|
|
236
|
-
* a busy server's long pause room.
|
|
436
|
+
* a busy server's long pause room. Requests a transport boundary kept are
|
|
437
|
+
* counted apart from it (keptRequests), with a bound of the same size past
|
|
438
|
+
* which they're dropped, since no server waits on them. */
|
|
237
439
|
static MAX_HELD_REQUESTS = 10_000;
|
|
238
440
|
droppedBufferedEvents = 0;
|
|
239
441
|
/** Requests refused at the budget since the planes last opened (ready()),
|
|
@@ -249,11 +451,27 @@ export class McplServerConnection extends EventEmitter {
|
|
|
249
451
|
/** Consecutive failed connect attempts since the last successful handshake.
|
|
250
452
|
* Drives the exponential backoff and is reported on reconnect events. */
|
|
251
453
|
reconnectAttempts = 0;
|
|
454
|
+
/** The reconnect attempt in flight, so close() can abort it and wait for
|
|
455
|
+
* its cleanup. */
|
|
456
|
+
reconnecting = null;
|
|
457
|
+
/**
|
|
458
|
+
* Launches whose cleanup couldn't confirm that the child exited. While any
|
|
459
|
+
* is held, reconnecting is halted, since another launch could run beside
|
|
460
|
+
* one that's still alive. close() retries their reap.
|
|
461
|
+
*/
|
|
462
|
+
unreaped = [];
|
|
252
463
|
/** Whether a background reconnect loop will revive this connection after a
|
|
253
|
-
* drop. False once close() has been called (explicit close stops retrying)
|
|
464
|
+
* drop. False once close() has been called (explicit close stops retrying),
|
|
465
|
+
* after a protocol-version verdict, and while a launch is unreaped. */
|
|
254
466
|
get willReconnect() {
|
|
255
467
|
return this.reconnectEnabled;
|
|
256
468
|
}
|
|
469
|
+
/** Whether a child this connection launched couldn't be confirmed exited.
|
|
470
|
+
* While it's true the connection owns that child, so whatever registered
|
|
471
|
+
* it keeps its id held; close() retries the reap. */
|
|
472
|
+
get holdsUnreapedChild() {
|
|
473
|
+
return this.unreaped.length > 0;
|
|
474
|
+
}
|
|
257
475
|
/** Closed with no reconnect to come: after close(), whether or not its
|
|
258
476
|
* transport close succeeded, or a drop with reconnect off. A connection
|
|
259
477
|
* between retries keeps reconnectEnabled, so it isn't closed for good. */
|
|
@@ -281,65 +499,205 @@ export class McplServerConnection extends EventEmitter {
|
|
|
281
499
|
* construction (when setupMessageRouting starts emitting) and now (when
|
|
282
500
|
* the caller has attached listeners via wireMcplEvents).
|
|
283
501
|
*
|
|
284
|
-
*
|
|
285
|
-
* when it closed, so what
|
|
286
|
-
* closed connection sends no response, so nothing it held
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* it held, such as a reconnect stub's connect-failed,
|
|
290
|
-
* listeners
|
|
502
|
+
* Once the connection is closed for good it readies nothing. Its policy was
|
|
503
|
+
* reset when it closed, so what the server sent that needs a grant would be
|
|
504
|
+
* refused, and a closed connection sends no response, so nothing it held
|
|
505
|
+
* could be answered. It delivers only its own reports of how it ended (see
|
|
506
|
+
* {@link deliverOwnReports}). A connection between retries isn't closed for
|
|
507
|
+
* good: what it held, such as a reconnect stub's connect-failed, reaches
|
|
508
|
+
* its listeners as it does any connection's, except the incoming messages
|
|
509
|
+
* and pushes a transport boundary kept, which wait for the next
|
|
510
|
+
* transport's grant (see {@link flushHeld}).
|
|
291
511
|
*/
|
|
292
512
|
ready() {
|
|
293
|
-
if (this.closedForGood)
|
|
513
|
+
if (this.closedForGood) {
|
|
514
|
+
this.deliverOwnReports();
|
|
294
515
|
return;
|
|
516
|
+
}
|
|
295
517
|
this.controlPlaneReady = true;
|
|
296
518
|
this.dataPlaneReady = true;
|
|
297
519
|
const pending = this.bufferedEvents;
|
|
298
|
-
this.
|
|
520
|
+
this.replaceHeld([]);
|
|
299
521
|
// The cap's drops are counted per pause: this flush ends one, so its line
|
|
300
522
|
// reports that pause's drops, and the next pause counts (and logs) its own.
|
|
301
523
|
const dropped = this.droppedBufferedEvents;
|
|
302
524
|
this.droppedBufferedEvents = 0;
|
|
303
|
-
this.heldRequests = 0;
|
|
304
525
|
this.refusedHeldRequests = 0;
|
|
526
|
+
this.droppedKeptRequests = 0;
|
|
305
527
|
if (pending.length > 200) {
|
|
306
528
|
console.error(`[mcpl] ${this.id}: flushing ${pending.length} buffered event(s) synchronously ` +
|
|
307
529
|
`(long pause backlog${dropped > 0 ? `; ${dropped} dropped at cap` : ''})`);
|
|
308
530
|
}
|
|
309
|
-
for
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
531
|
+
// Re-enter the gate for every buffered item. A control handler (notably
|
|
532
|
+
// tools/list_changed) may install a newer data-plane barrier while this
|
|
533
|
+
// snapshot is being flushed; later data items must observe that pause.
|
|
534
|
+
this.flushHeld(pending);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Re-emit a held snapshot through the admission gate (this.emit), inside
|
|
538
|
+
* the containment boundary every inbound event shares
|
|
539
|
+
* (dispatchInboundEvent), in admission order (admissionOrder). A transport
|
|
540
|
+
* boundary crossed during the flush (a listener that closes the connection,
|
|
541
|
+
* say) leaves the rest of the snapshot to the boundary rule, as if it had
|
|
542
|
+
* still been held: what the server said about its own process is dropped,
|
|
543
|
+
* and the rest is held again, after whatever this flush held.
|
|
544
|
+
*
|
|
545
|
+
* Between transports, the incoming messages and pushes a boundary kept
|
|
546
|
+
* stay held for the next transport's grant: admitted now, they would meet
|
|
547
|
+
* the empty grant the boundary left, with no transport to answer on. The
|
|
548
|
+
* host's own reports about the transport (a stub's connect-failed, say)
|
|
549
|
+
* need no grant and go on.
|
|
550
|
+
*/
|
|
551
|
+
flushHeld(snapshot) {
|
|
552
|
+
const waits = (item) => item.kept === true && McplServerConnection.WORLD_EVENTS.has(item.event);
|
|
553
|
+
let pending = snapshot;
|
|
554
|
+
if (!this.isConnected && snapshot.some(waits)) {
|
|
555
|
+
this.replaceHeld([...this.bufferedEvents, ...snapshot.filter(waits)]);
|
|
556
|
+
pending = snapshot.filter((item) => !waits(item));
|
|
314
557
|
}
|
|
558
|
+
const ordered = McplServerConnection.admissionOrder(pending);
|
|
559
|
+
const epoch = this.transportEpoch;
|
|
560
|
+
for (let index = 0; index < ordered.length; index++) {
|
|
561
|
+
if (this.transportEpoch !== epoch) {
|
|
562
|
+
this.replaceHeld([...this.bufferedEvents, ...this.keepAcrossBoundary(ordered.slice(index))]);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const { event, args, kept } = ordered[index];
|
|
566
|
+
// What it can't admit yet is held again, as the admission gate would
|
|
567
|
+
// hold it, with what a boundary kept still marked as kept.
|
|
568
|
+
if (kept && !this.planeReadyFor(event))
|
|
569
|
+
this.hold(event, args, kept);
|
|
570
|
+
else
|
|
571
|
+
this.dispatchInboundEvent(event, ...args);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** Whether the plane an event belongs to is open (lifecycle events always
|
|
575
|
+
* pass), as the admission gate (emit) decides it. */
|
|
576
|
+
planeReadyFor(event) {
|
|
577
|
+
return McplServerConnection.isLifecycleEvent(event)
|
|
578
|
+
|| (McplServerConnection.isControlPlaneEvent(event) ? this.controlPlaneReady : this.dataPlaneReady);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* The order a flush admits a held snapshot in: as it was held, except that
|
|
582
|
+
* when it holds items a transport boundary kept, the current transport's
|
|
583
|
+
* channel registrations (`channels-register`, `channels-changed`) go
|
|
584
|
+
* first, each unless it names a channel that something the transport sent
|
|
585
|
+
* before it, and that stays where it was held, also names (below). The
|
|
586
|
+
* registry refuses a message for a channel it doesn't know
|
|
587
|
+
* (§14.5). It keeps a server's channels across a reconnect, but a
|
|
588
|
+
* registration the old process sent that was still held at the boundary
|
|
589
|
+
* was dropped with it (keepAcrossBoundary), so the next process registers
|
|
590
|
+
* that channel anew, behind what was kept: a kept message for it admitted
|
|
591
|
+
* first would be refused, and lost, since a bridge that recorded it as
|
|
592
|
+
* forwarded doesn't send it again.
|
|
593
|
+
* Nothing else moves, so a snapshot with nothing kept is flushed as it was
|
|
594
|
+
* held. A registration also passes whatever its own transport sent before
|
|
595
|
+
* it, which a server that announces its channels first, as a bridge does,
|
|
596
|
+
* never has. A removal stays where it was held, with whatever else it adds
|
|
597
|
+
* or updates, as everything does that isn't a registration. A registration
|
|
598
|
+
* that names a channel which an earlier removal, or an earlier registration
|
|
599
|
+
* that stays, names stays where it was held too, so the writes to one
|
|
600
|
+
* channel keep their order: a channel's removal and its re-registration,
|
|
601
|
+
* and two registrations or updates of it. In a flush that opens both
|
|
602
|
+
* planes, what the transport sent before a removal is admitted before it,
|
|
603
|
+
* its messages for that channel included. A flush that opens only the control
|
|
604
|
+
* plane, as during a quiesce, admits the control traffic, a removal
|
|
605
|
+
* included, and holds the data plane's again until that plane opens, as
|
|
606
|
+
* live traffic is during a quiesce: a message held for a channel a removal
|
|
607
|
+
* took is then refused as the data plane opens.
|
|
608
|
+
*/
|
|
609
|
+
static admissionOrder(pending) {
|
|
610
|
+
if (!pending.some((item) => item.kept))
|
|
611
|
+
return pending;
|
|
612
|
+
const registrations = new Set();
|
|
613
|
+
// The channels named so far by a removal or registration that stays
|
|
614
|
+
// where it was held.
|
|
615
|
+
const staying = new Set();
|
|
616
|
+
for (const item of pending) {
|
|
617
|
+
if (item.kept)
|
|
618
|
+
continue;
|
|
619
|
+
if (item.event !== 'channels-register' && item.event !== 'channels-changed')
|
|
620
|
+
continue;
|
|
621
|
+
const removes = McplServerConnection.removedChannelIds(item);
|
|
622
|
+
const registers = McplServerConnection.registeredChannelIds(item);
|
|
623
|
+
if (removes.length === 0 && !registers.some((id) => staying.has(id))) {
|
|
624
|
+
registrations.add(item);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
for (const id of [...removes, ...registers])
|
|
628
|
+
staying.add(id);
|
|
629
|
+
}
|
|
630
|
+
if (registrations.size === 0)
|
|
631
|
+
return pending;
|
|
632
|
+
return [...registrations, ...pending.filter((item) => !registrations.has(item))];
|
|
633
|
+
}
|
|
634
|
+
/** The channels a held `channels-changed` removes. The registry takes any
|
|
635
|
+
* entry of `removed` as a channel id, by its string form, so a number
|
|
636
|
+
* removes the channel of its digits; an object is left out here, since
|
|
637
|
+
* its string form can throw. */
|
|
638
|
+
static removedChannelIds(item) {
|
|
639
|
+
if (item.event !== 'channels-changed')
|
|
640
|
+
return [];
|
|
641
|
+
const params = item.args[0];
|
|
642
|
+
return Array.isArray(params?.removed)
|
|
643
|
+
? params.removed.filter((id) => typeof id !== 'object' || id === null).map((id) => String(id))
|
|
644
|
+
: [];
|
|
645
|
+
}
|
|
646
|
+
/** The channels a held `channels-register` registers, or a held
|
|
647
|
+
* `channels-changed` adds or updates. */
|
|
648
|
+
static registeredChannelIds(item) {
|
|
649
|
+
const params = item.args[0];
|
|
650
|
+
const lists = item.event === 'channels-register' ? [params?.channels] : [params?.added, params?.updated];
|
|
651
|
+
return lists.flatMap((list) => (Array.isArray(list) ? list : []))
|
|
652
|
+
.map((channel) => channel?.id)
|
|
653
|
+
.filter((id) => typeof id === 'string');
|
|
315
654
|
}
|
|
316
655
|
/**
|
|
317
656
|
* Release only server-establishment and lifecycle/control traffic. Responses
|
|
318
657
|
* to host requests are never event-buffered, so tools/list and tools/call
|
|
319
658
|
* responses remain live as well. Inference-bearing requests stay queued in
|
|
320
659
|
* their original relative order until {@link ready} is called. Like
|
|
321
|
-
* {@link ready},
|
|
660
|
+
* {@link ready}, once the connection is closed for good it delivers only
|
|
661
|
+
* its own reports of how it ended.
|
|
322
662
|
*/
|
|
323
663
|
readyControlPlane() {
|
|
324
|
-
if (this.closedForGood)
|
|
664
|
+
if (this.closedForGood) {
|
|
665
|
+
this.deliverOwnReports();
|
|
325
666
|
return;
|
|
667
|
+
}
|
|
326
668
|
this.controlPlaneReady = true;
|
|
327
669
|
const pending = this.bufferedEvents;
|
|
328
|
-
this.
|
|
329
|
-
this.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
670
|
+
this.replaceHeld([]);
|
|
671
|
+
// Re-enter the ADMISSION gate (this.emit), never super.emit: control
|
|
672
|
+
// events include channels-register/changed, which are grant-gated —
|
|
673
|
+
// the old direct flush bypassed positive-grant enforcement entirely
|
|
674
|
+
// (PR #79 review blocker 3). Data-plane items re-buffer in order via
|
|
675
|
+
// the same call, since dataPlaneReady is still false.
|
|
676
|
+
this.flushHeld(pending);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* A connection closed for good still says how it ended. Its own buffered
|
|
680
|
+
* connect-failed and reconnect-failed, such as the permanent failure of a
|
|
681
|
+
* launch held as unreaped before the first gate released it, are the
|
|
682
|
+
* framework's records of it: they need no grant, and its listeners act on
|
|
683
|
+
* them (a trace, and for a permanent failure an alert and its tools leaving
|
|
684
|
+
* the list). What the server sent stays held.
|
|
685
|
+
*/
|
|
686
|
+
deliverOwnReports() {
|
|
687
|
+
const own = (item) => item.event === 'connect-failed' || item.event === 'reconnect-failed';
|
|
688
|
+
const reports = this.bufferedEvents.filter(own);
|
|
689
|
+
if (reports.length === 0)
|
|
690
|
+
return;
|
|
691
|
+
// The host's own reports have no responder, so the request counts stand.
|
|
692
|
+
this.bufferedEvents = this.bufferedEvents.filter((item) => !own(item));
|
|
693
|
+
for (const { event, args } of reports)
|
|
694
|
+
super.emit(event, ...args);
|
|
338
695
|
}
|
|
339
696
|
/**
|
|
340
697
|
* Close the data plane synchronously while leaving an already-released
|
|
341
|
-
* control plane live. Used before
|
|
342
|
-
*
|
|
698
|
+
* control plane live. Used before list-change reconciliation so no new
|
|
699
|
+
* inference event can beat barrier installation. A reconnect closes both
|
|
700
|
+
* planes instead.
|
|
343
701
|
*/
|
|
344
702
|
pauseDataPlane() {
|
|
345
703
|
this.dataPlaneReady = false;
|
|
@@ -351,10 +709,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
351
709
|
*/
|
|
352
710
|
emit(event, ...args) {
|
|
353
711
|
const name = typeof event === 'string' ? event : '';
|
|
354
|
-
if (
|
|
355
|
-
|| (McplServerConnection.isControlPlaneEvent(name)
|
|
356
|
-
? this.controlPlaneReady
|
|
357
|
-
: this.dataPlaneReady)) {
|
|
712
|
+
if (this.planeReadyFor(name)) {
|
|
358
713
|
// Positive-grant admission (§5.4/§14.1), at the single choke-point
|
|
359
714
|
// every path shares: live routing AND buffer flushes re-enter here, so
|
|
360
715
|
// an event buffered while the connection was staged is authorized
|
|
@@ -412,6 +767,13 @@ export class McplServerConnection extends EventEmitter {
|
|
|
412
767
|
}
|
|
413
768
|
return super.emit(event, ...args);
|
|
414
769
|
}
|
|
770
|
+
return this.hold(name, args);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Hold an event until its plane opens, `kept` when a transport boundary
|
|
774
|
+
* keeps it for the next transport's grant (afterBoundary).
|
|
775
|
+
*/
|
|
776
|
+
hold(name, args, kept) {
|
|
415
777
|
// Held until the plane opens. A long pause (a quiesce, say) on a busy or a
|
|
416
778
|
// runaway server would otherwise grow this array without bound, so
|
|
417
779
|
// requests and notifications each have a bound, counted apart. A request
|
|
@@ -420,11 +782,28 @@ export class McplServerConnection extends EventEmitter {
|
|
|
420
782
|
// the code of the host's other transient refusals, and its server can send
|
|
421
783
|
// it again. A notification has no one to tell, so past
|
|
422
784
|
// MAX_HELD_NOTIFICATIONS (issue #122) the oldest held notification is
|
|
423
|
-
// dropped to make room. A
|
|
424
|
-
//
|
|
425
|
-
//
|
|
785
|
+
// dropped to make room. A request a closed transport left (`kept`) has
|
|
786
|
+
// no server waiting on it, so it's counted apart, as keptRequests, and
|
|
787
|
+
// dropped past its own bound. A flush takes the whole buffer and zeroes
|
|
788
|
+
// its counts, so what it holds again was admitted within these bounds
|
|
789
|
+
// and stays within them.
|
|
426
790
|
const responder = args[1];
|
|
427
791
|
const isRequest = McplServerConnection.hasResponder({ args });
|
|
792
|
+
if (isRequest && kept) {
|
|
793
|
+
// A request a closed transport left (keptRequests): no server waits on
|
|
794
|
+
// it, so past its own bound it's dropped, with nobody to refuse.
|
|
795
|
+
if (this.keptRequests >= McplServerConnection.MAX_HELD_REQUESTS) {
|
|
796
|
+
this.droppedKeptRequests++;
|
|
797
|
+
if (this.droppedKeptRequests % 100 === 1) {
|
|
798
|
+
console.error(`[mcpl] ${this.id}: kept-request bound (${McplServerConnection.MAX_HELD_REQUESTS}) — ` +
|
|
799
|
+
`dropped ${this.droppedKeptRequests} request(s) a closed transport left`);
|
|
800
|
+
}
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
this.bufferedEvents.push({ event: name, args, kept });
|
|
804
|
+
this.keptRequests++;
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
428
807
|
if (isRequest && this.heldRequests >= McplServerConnection.MAX_HELD_REQUESTS) {
|
|
429
808
|
this.refusedHeldRequests++;
|
|
430
809
|
if (this.refusedHeldRequests % 100 === 1) {
|
|
@@ -434,7 +813,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
434
813
|
responder?.respondError?.(-32000, `the host is busy: ${McplServerConnection.MAX_HELD_REQUESTS} of this server's requests are already waiting; send this one again later`);
|
|
435
814
|
return true;
|
|
436
815
|
}
|
|
437
|
-
if (!isRequest && this.bufferedEvents.length - this.heldRequests >= McplServerConnection.MAX_HELD_NOTIFICATIONS) {
|
|
816
|
+
if (!isRequest && this.bufferedEvents.length - this.heldRequests - this.keptRequests >= McplServerConnection.MAX_HELD_NOTIFICATIONS) {
|
|
438
817
|
this.droppedBufferedEvents++;
|
|
439
818
|
if (this.droppedBufferedEvents % 100 === 1) {
|
|
440
819
|
console.error(`[mcpl] ${this.id}: buffered-event cap (${McplServerConnection.MAX_HELD_NOTIFICATIONS}) — ` +
|
|
@@ -444,7 +823,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
444
823
|
if (oldest >= 0)
|
|
445
824
|
this.bufferedEvents.splice(oldest, 1);
|
|
446
825
|
}
|
|
447
|
-
this.bufferedEvents.push({ event: name, args });
|
|
826
|
+
this.bufferedEvents.push(kept ? { event: name, args, kept } : { event: name, args });
|
|
448
827
|
if (isRequest)
|
|
449
828
|
this.heldRequests++;
|
|
450
829
|
return true;
|
|
@@ -454,6 +833,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
454
833
|
const responder = item.args[1];
|
|
455
834
|
return typeof responder?.respond === 'function';
|
|
456
835
|
}
|
|
836
|
+
/** Events routed from a server's transport that report the world, which
|
|
837
|
+
* the next process doesn't send again when its bridge records them as
|
|
838
|
+
* forwarded first: kept at a transport boundary while held. */
|
|
839
|
+
static WORLD_EVENTS = new Set(['channels-incoming', 'push-event']);
|
|
840
|
+
/** Events routed from a server's transport that speak for its process, not
|
|
841
|
+
* the world: dropped at a transport boundary while held. */
|
|
842
|
+
static get PROCESS_EVENTS() {
|
|
843
|
+
return new Set(Object.values(McplServerConnection.METHOD_TO_EVENT)
|
|
844
|
+
.filter((event) => !McplServerConnection.WORLD_EVENTS.has(event)));
|
|
845
|
+
}
|
|
457
846
|
/** Event-name form of METHOD_TO_REQUIRED_CAPABILITY, for admission-time
|
|
458
847
|
* enforcement in emit(). */
|
|
459
848
|
static EVENT_TO_REQUIRED_CAPABILITY = {
|
|
@@ -501,20 +890,46 @@ export class McplServerConnection extends EventEmitter {
|
|
|
501
890
|
* capability negotiation, and return a ready-to-use connection.
|
|
502
891
|
*
|
|
503
892
|
* Works over stdio (spawned child) or WebSocket, selected by `config` (see
|
|
504
|
-
* {@link openTransport}). Throws if the
|
|
505
|
-
*
|
|
893
|
+
* {@link openTransport}). Throws if the configuration can't be used (before
|
|
894
|
+
* anything is spawned or dialed), the transport can't be opened, the server
|
|
895
|
+
* closes/errors before the handshake, or the handshake times out. If the
|
|
896
|
+
* failed launch's cleanup can't confirm its child exited, it throws
|
|
897
|
+
* {@link McplUnreapedLaunchError}, whose connection owns that launch.
|
|
506
898
|
*/
|
|
507
|
-
static async connect(config, hostCapabilities) {
|
|
508
|
-
|
|
899
|
+
static async connect(config, hostCapabilities, signal) {
|
|
900
|
+
McplServerConnection.checkConfig(config);
|
|
901
|
+
return McplServerConnection.connectChecked(config, hostCapabilities, signal);
|
|
902
|
+
}
|
|
903
|
+
/** {@link connect}, for a configuration its caller has already checked
|
|
904
|
+
* (and warned about) once. `signal` ends the connect at once, its launch
|
|
905
|
+
* reaped as for any failed handshake: its caller's stop() or a
|
|
906
|
+
* disconnect of its id. */
|
|
907
|
+
static async connectChecked(config, hostCapabilities, signal) {
|
|
908
|
+
let established;
|
|
909
|
+
try {
|
|
910
|
+
established = await McplServerConnection.handshake(config, hostCapabilities, signal);
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
if (!(error instanceof HandshakeCleanupError))
|
|
914
|
+
throw error;
|
|
915
|
+
// The launch may still be running. A connection owns it, closed and
|
|
916
|
+
// halted, so the caller can retry the reap by closing it.
|
|
917
|
+
const owner = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
918
|
+
owner.holdUnreaped(error.transport, error.cleanup);
|
|
919
|
+
throw new McplUnreapedLaunchError(owner, error.failure, error.cleanup);
|
|
920
|
+
}
|
|
921
|
+
const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = established;
|
|
509
922
|
const connection = new McplServerConnection(config.id, capabilities, transport);
|
|
510
923
|
connection.droppedCapabilities = droppedCapabilities;
|
|
511
924
|
connection.mcpToolsAdvertised = mcpToolsAdvertised;
|
|
925
|
+
connection.protocolVersion = protocolVersion;
|
|
512
926
|
connection.allowHostCommands = config.allowHostCommands === true;
|
|
513
927
|
connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
514
|
-
//
|
|
928
|
+
// Its configuration, which a reconnect reads and every log line of the
|
|
929
|
+
// server's text redacts with. Only `true` turns reconnect on, as at
|
|
515
930
|
// every other reader of it.
|
|
931
|
+
connection.config = config;
|
|
516
932
|
if (config.reconnect === true) {
|
|
517
|
-
connection.config = config;
|
|
518
933
|
connection.hostCapabilities = hostCapabilities;
|
|
519
934
|
connection.reconnectEnabled = true;
|
|
520
935
|
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
@@ -522,15 +937,45 @@ export class McplServerConnection extends EventEmitter {
|
|
|
522
937
|
}
|
|
523
938
|
return connection;
|
|
524
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* This engine's configuration check, made before anything is spawned or
|
|
942
|
+
* dialed: every problem {@link checkServerConfig} finds, the same check the
|
|
943
|
+
* framework applies, and the modern family, which belongs to
|
|
944
|
+
* ModernMcpConnection (an http(s) url is never dialed as a WebSocket).
|
|
945
|
+
* Every configuration that worked before still passes but the one
|
|
946
|
+
* serverConfigProblems names.
|
|
947
|
+
*/
|
|
948
|
+
static checkConfig(config) {
|
|
949
|
+
const { family } = checkServerConfig(config);
|
|
950
|
+
if (family === 'modern') {
|
|
951
|
+
throw new Error(`MCP server "${config.id}" is configured for modern MCP (${MODERN_MCP_PROTOCOL_VERSION}); ` +
|
|
952
|
+
`McplServerConnection speaks the legacy family (${MCP_PROTOCOL_VERSION} + MCPL) only`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
525
955
|
/**
|
|
526
956
|
* Open a fresh transport and run the MCP/MCPL initialize handshake over it,
|
|
527
957
|
* returning the connected transport plus the negotiated capabilities. Shared
|
|
528
958
|
* by {@link connect} and {@link attemptReconnect} so both transports handshake
|
|
529
959
|
* identically. On any failure the transport is closed so a spawned child /
|
|
530
|
-
* open socket never leaks.
|
|
960
|
+
* open socket never leaks. If that close can't confirm the child exited,
|
|
961
|
+
* the failure is a {@link HandshakeCleanupError} carrying the transport, so
|
|
962
|
+
* the caller keeps owning it. `signal` ends a handshake whose connection is
|
|
963
|
+
* closed meanwhile: it cuts a WebSocket dial short, is checked once the
|
|
964
|
+
* transport is open, and races the initialize answer.
|
|
531
965
|
*/
|
|
532
|
-
static async handshake(config, hostCapabilities) {
|
|
533
|
-
const
|
|
966
|
+
static async handshake(config, hostCapabilities, signal) {
|
|
967
|
+
const closedWhileConnecting = () => new Error(`MCPL server "${config.id}" was closed while connecting`);
|
|
968
|
+
if (signal?.aborted)
|
|
969
|
+
throw closedWhileConnecting();
|
|
970
|
+
let transport;
|
|
971
|
+
try {
|
|
972
|
+
transport = await openTransport(config, signal);
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
if (signal?.aborted)
|
|
976
|
+
throw closedWhileConnecting();
|
|
977
|
+
throw error;
|
|
978
|
+
}
|
|
534
979
|
// Once the handshake stops listening for 'error', this logs what the
|
|
535
980
|
// transport reports, until the handshake hands the transport on, or for
|
|
536
981
|
// good if the handshake fails. EventEmitter throws an 'error' that has no
|
|
@@ -538,9 +983,11 @@ export class McplServerConnection extends EventEmitter {
|
|
|
538
983
|
// WebSocket frame that arrives with the initialize response, or while a
|
|
539
984
|
// failed handshake's close() runs, is such an error.
|
|
540
985
|
const logAfterSettled = (err) => {
|
|
541
|
-
console.error(`[mcpl] ${config.id}: transport error after the handshake settled: ${err.message}`);
|
|
986
|
+
console.error(`[mcpl] ${config.id}: transport error after the handshake settled: ${redactServerText(err.message, config)}`);
|
|
542
987
|
};
|
|
543
988
|
try {
|
|
989
|
+
if (signal?.aborted)
|
|
990
|
+
throw closedWhileConnecting();
|
|
544
991
|
const initId = 0; // use id=0 for the handshake
|
|
545
992
|
const initRequest = {
|
|
546
993
|
jsonrpc: '2.0',
|
|
@@ -552,9 +999,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
552
999
|
clientInfo: { name: 'agent-framework', version: '1.0.0' },
|
|
553
1000
|
},
|
|
554
1001
|
};
|
|
555
|
-
// Await the initialize response, racing an early transport close/error
|
|
556
|
-
// a timeout.
|
|
557
|
-
const { mcpl: rawCapabilities, mcpToolsAdvertised } = await new Promise((resolve, reject) => {
|
|
1002
|
+
// Await the initialize response, racing an early transport close/error,
|
|
1003
|
+
// a timeout and the owner's close. Every path cleans up its listeners.
|
|
1004
|
+
const { mcpl: rawCapabilities, mcpToolsAdvertised, protocolVersion } = await new Promise((resolve, reject) => {
|
|
558
1005
|
// When the transport fails, the end of the server's stderr often says
|
|
559
1006
|
// why, such as a missing token or a crash at startup. It goes on the
|
|
560
1007
|
// error as `stderrTail`, never into the message: an error's message
|
|
@@ -568,9 +1015,14 @@ export class McplServerConnection extends EventEmitter {
|
|
|
568
1015
|
transport.off('line', onLine);
|
|
569
1016
|
transport.off('close', onClose);
|
|
570
1017
|
transport.off('error', onError);
|
|
1018
|
+
signal?.removeEventListener('abort', onAbort);
|
|
571
1019
|
transport.on('error', logAfterSettled);
|
|
572
1020
|
clearTimeout(timer);
|
|
573
1021
|
};
|
|
1022
|
+
const onAbort = () => {
|
|
1023
|
+
cleanup();
|
|
1024
|
+
reject(closedWhileConnecting());
|
|
1025
|
+
};
|
|
574
1026
|
const onLine = (line) => {
|
|
575
1027
|
let msg;
|
|
576
1028
|
try {
|
|
@@ -604,10 +1056,21 @@ export class McplServerConnection extends EventEmitter {
|
|
|
604
1056
|
// exception, which ends a host that doesn't catch them, and a
|
|
605
1057
|
// handshake left to wait forever. It's the server's text in an
|
|
606
1058
|
// error's message, so it's bounded as errors.ts says.
|
|
607
|
-
reject(
|
|
1059
|
+
reject(msg.error.code === UNSUPPORTED_PROTOCOL_VERSION_CODE
|
|
1060
|
+
? protocolVersionRejection(config.id, msg.error)
|
|
1061
|
+
: new Error(`MCPL server "${config.id}" initialize error: ${boundedForLog(describeCause(msg.error.message))}`));
|
|
608
1062
|
return;
|
|
609
1063
|
}
|
|
610
1064
|
const result = msg.result;
|
|
1065
|
+
// The server's answer is the established revision. This engine
|
|
1066
|
+
// implements exactly one, so any other answer (or none) means the
|
|
1067
|
+
// two sides never agreed on what they're speaking.
|
|
1068
|
+
const returned = typeof result?.protocolVersion === 'string' ? result.protocolVersion : null;
|
|
1069
|
+
if (returned !== MCP_PROTOCOL_VERSION) {
|
|
1070
|
+
reject(new McplProtocolVersionError(`MCPL server "${config.id}" answered initialize with protocolVersion ` +
|
|
1071
|
+
`${returned === null ? '(none)' : JSON.stringify(returned)}; this client's legacy engine speaks only ${MCP_PROTOCOL_VERSION}`, 'mismatch', returned, null));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
611
1074
|
const caps = result?.capabilities;
|
|
612
1075
|
const experimental = caps?.experimental;
|
|
613
1076
|
// §5.1: `tools` capability is the OUTER standard MCP member — the
|
|
@@ -616,6 +1079,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
616
1079
|
resolve({
|
|
617
1080
|
mcpl: experimental?.mcpl ?? null,
|
|
618
1081
|
mcpToolsAdvertised: caps?.tools !== undefined,
|
|
1082
|
+
protocolVersion: returned,
|
|
619
1083
|
});
|
|
620
1084
|
};
|
|
621
1085
|
const onClose = (info) => {
|
|
@@ -637,6 +1101,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
637
1101
|
cleanup();
|
|
638
1102
|
reject(failed(`MCPL server "${config.id}" initialize handshake timed out`));
|
|
639
1103
|
}, INITIALIZE_TIMEOUT_MS);
|
|
1104
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
640
1105
|
transport.on('line', onLine);
|
|
641
1106
|
transport.on('close', onClose);
|
|
642
1107
|
transport.on('error', onError);
|
|
@@ -660,11 +1125,18 @@ export class McplServerConnection extends EventEmitter {
|
|
|
660
1125
|
// listener of its own, in the continuation of this return, before any
|
|
661
1126
|
// I/O callback can emit.
|
|
662
1127
|
transport.off('error', logAfterSettled);
|
|
663
|
-
return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised };
|
|
1128
|
+
return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised, protocolVersion };
|
|
664
1129
|
}
|
|
665
1130
|
catch (err) {
|
|
666
|
-
// Never leak the child / socket if the handshake fails.
|
|
667
|
-
|
|
1131
|
+
// Never leak the child / socket if the handshake fails. A cleanup that
|
|
1132
|
+
// can't confirm the child exited is part of the failure, never
|
|
1133
|
+
// swallowed: the launch may still be running, so the caller keeps it.
|
|
1134
|
+
try {
|
|
1135
|
+
await transport.close();
|
|
1136
|
+
}
|
|
1137
|
+
catch (cleanup) {
|
|
1138
|
+
throw new HandshakeCleanupError(errorOf(err), errorOf(cleanup), transport);
|
|
1139
|
+
}
|
|
668
1140
|
throw err;
|
|
669
1141
|
}
|
|
670
1142
|
}
|
|
@@ -672,30 +1144,43 @@ export class McplServerConnection extends EventEmitter {
|
|
|
672
1144
|
* Connect with reconnect support.
|
|
673
1145
|
* When `config.reconnect` is true and the initial connection fails,
|
|
674
1146
|
* resolves immediately with null capabilities and retries in the background.
|
|
1147
|
+
* A configuration this engine can't use, or a protocol-version verdict,
|
|
1148
|
+
* still throws: retrying can't change either. So does a failed launch that
|
|
1149
|
+
* can't be reaped, as in {@link connect}: no retry starts beside a child
|
|
1150
|
+
* that may still be running, and the {@link McplUnreapedLaunchError}
|
|
1151
|
+
* carries the connection that owns it, whose close() retries the reap.
|
|
675
1152
|
* Adapted from Anarchid/agent-framework@mcpl-module-proto.
|
|
676
1153
|
*/
|
|
677
|
-
static async connectWithReconnect(config, hostCapabilities) {
|
|
1154
|
+
static async connectWithReconnect(config, hostCapabilities, signal) {
|
|
1155
|
+
// Outside the retry path below: a configuration error is never retried.
|
|
1156
|
+
McplServerConnection.checkConfig(config);
|
|
678
1157
|
try {
|
|
679
|
-
return await McplServerConnection.
|
|
1158
|
+
return await McplServerConnection.connectChecked(config, hostCapabilities, signal);
|
|
680
1159
|
}
|
|
681
1160
|
catch (error) {
|
|
682
|
-
|
|
1161
|
+
// No common protocol revision is a configuration fact, not an outage:
|
|
1162
|
+
// a retry stub would restart the server forever for nothing. A connect
|
|
1163
|
+
// its caller ended (stop(), or a disconnect of its id) isn't retried
|
|
1164
|
+
// either, nor is a launch that couldn't be reaped.
|
|
1165
|
+
if (config.reconnect !== true || error instanceof McplProtocolVersionError || error instanceof McplUnreapedLaunchError || signal?.aborted) {
|
|
683
1166
|
throw error;
|
|
684
1167
|
}
|
|
685
|
-
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
686
|
-
console.error(`MCPL server "${config.id}" initial connect failed, will retry: ${error.message}${withStderr(error)}`);
|
|
687
|
-
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
688
1168
|
// Surface the initial failure. The event is buffered by the emit()
|
|
689
1169
|
// override until the host wires listeners and calls ready(), so it is
|
|
690
1170
|
// not lost in the window before wireMcplEvents runs.
|
|
1171
|
+
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
1172
|
+
console.error(`MCPL server "${config.id}" initial connect failed, will retry: ${redactServerText(error.message, config)}${withStderr(error, (text) => redactServerText(text, config))}`);
|
|
1173
|
+
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
691
1174
|
const stderrTail = stderrTailOf(error);
|
|
692
1175
|
stub.emit('connect-failed', { error: error.message, ...(stderrTail ? { stderrTail } : {}), attempt: 0 });
|
|
1176
|
+
stub.scheduleReconnect();
|
|
693
1177
|
return stub;
|
|
694
1178
|
}
|
|
695
1179
|
}
|
|
696
1180
|
/**
|
|
697
|
-
* Create a disconnected stub connection
|
|
698
|
-
*
|
|
1181
|
+
* Create a disconnected stub connection, for a failed initial connect: one
|
|
1182
|
+
* that will reconnect in the background (its caller schedules that), or one
|
|
1183
|
+
* that owns a launch that couldn't be reaped.
|
|
699
1184
|
* @internal
|
|
700
1185
|
*/
|
|
701
1186
|
static createDisconnectedStub(config, hostCapabilities) {
|
|
@@ -705,14 +1190,31 @@ export class McplServerConnection extends EventEmitter {
|
|
|
705
1190
|
stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
706
1191
|
stub.config = config;
|
|
707
1192
|
stub.hostCapabilities = hostCapabilities;
|
|
708
|
-
stub.reconnectEnabled = true;
|
|
1193
|
+
stub.reconnectEnabled = config.reconnect === true;
|
|
709
1194
|
stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
710
1195
|
stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
711
1196
|
stub.reconnectAttempts = 1; // the initial connect already failed
|
|
712
|
-
// Schedule background reconnect
|
|
713
|
-
stub.scheduleReconnect();
|
|
714
1197
|
return stub;
|
|
715
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Keep a launch whose cleanup couldn't confirm its child exited, and halt
|
|
1201
|
+
* reconnecting: another launch could run beside one that's still alive.
|
|
1202
|
+
* close() retries the reap.
|
|
1203
|
+
*/
|
|
1204
|
+
holdUnreaped(transport, failure) {
|
|
1205
|
+
this.reconnectEnabled = false;
|
|
1206
|
+
if (this.reconnectTimer) {
|
|
1207
|
+
clearTimeout(this.reconnectTimer);
|
|
1208
|
+
this.reconnectTimer = null;
|
|
1209
|
+
}
|
|
1210
|
+
if (!this.unreaped.some((held) => held.transport === transport))
|
|
1211
|
+
this.unreaped.push({ transport, failure });
|
|
1212
|
+
}
|
|
1213
|
+
/** The halt, as a reconnect event's failure text. */
|
|
1214
|
+
unreapedFailure() {
|
|
1215
|
+
const held = this.unreaped[0];
|
|
1216
|
+
return `the previous launch could not be reaped (${held?.failure.message ?? 'unknown'}); reconnecting is halted`;
|
|
1217
|
+
}
|
|
716
1218
|
// ==========================================================================
|
|
717
1219
|
// Outbound requests (return Promises)
|
|
718
1220
|
// ==========================================================================
|
|
@@ -741,8 +1243,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
741
1243
|
sendFeatureSetsUpdateRequest(params, timeoutMs = 15_000) {
|
|
742
1244
|
return this.sendRequest(McplMethod.FeatureSetsUpdate, params, { timeoutMs });
|
|
743
1245
|
}
|
|
744
|
-
/** Send `inference/chunk` notification
|
|
745
|
-
|
|
1246
|
+
/** Send `inference/chunk` notification, on the transport `epoch` names: a
|
|
1247
|
+
* chunk of a request from a transport that has closed goes nowhere, as
|
|
1248
|
+
* that request's answer does. */
|
|
1249
|
+
sendInferenceChunk(params, epoch = this.transportEpoch) {
|
|
1250
|
+
if (epoch !== this.transportEpoch)
|
|
1251
|
+
return;
|
|
746
1252
|
this.sendNotification(McplMethod.InferenceChunk, params);
|
|
747
1253
|
}
|
|
748
1254
|
/** Send `inference/lifecycle` notification (§10.5). Best-effort. */
|
|
@@ -831,9 +1337,45 @@ export class McplServerConnection extends EventEmitter {
|
|
|
831
1337
|
// ==========================================================================
|
|
832
1338
|
// Standard MCP methods
|
|
833
1339
|
// ==========================================================================
|
|
834
|
-
/**
|
|
835
|
-
|
|
836
|
-
|
|
1340
|
+
/**
|
|
1341
|
+
* Send `tools/list` and return the server's complete tool inventory,
|
|
1342
|
+
* following `nextCursor` across pages (2024-11-05 pagination). Cursors are
|
|
1343
|
+
* opaque strings, `''` included: only an absent or null `nextCursor` ends
|
|
1344
|
+
* the inventory (a null one is how some servers say "no more", and main
|
|
1345
|
+
* read it as a complete page). A page that isn't well formed (no `tools` array, a cursor
|
|
1346
|
+
* that isn't a string), or cursors that don't terminate (a repeat, or more
|
|
1347
|
+
* than {@link MAX_TOOL_LIST_PAGES} pages), are errors, never a partial or
|
|
1348
|
+
* empty inventory published as complete.
|
|
1349
|
+
*/
|
|
1350
|
+
async sendToolsList() {
|
|
1351
|
+
const tools = [];
|
|
1352
|
+
const seen = new Set();
|
|
1353
|
+
let cursor;
|
|
1354
|
+
let first;
|
|
1355
|
+
for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) {
|
|
1356
|
+
const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor });
|
|
1357
|
+
const malformed = (why) => new Error(`MCPL server "${this.id}" sent a malformed tools/list page ${page + 1}: ${why}`);
|
|
1358
|
+
if (!result || typeof result !== 'object' || !Array.isArray(result.tools)) {
|
|
1359
|
+
throw malformed('no tools array');
|
|
1360
|
+
}
|
|
1361
|
+
first ??= result;
|
|
1362
|
+
tools.push(...result.tools);
|
|
1363
|
+
if (result.nextCursor === undefined || result.nextCursor === null) {
|
|
1364
|
+
// The first page's other fields are kept as they came, so a
|
|
1365
|
+
// single-page answer is returned exactly as before.
|
|
1366
|
+
const { nextCursor: _done, ...rest } = first;
|
|
1367
|
+
return { ...rest, tools };
|
|
1368
|
+
}
|
|
1369
|
+
if (typeof result.nextCursor !== 'string') {
|
|
1370
|
+
throw malformed(`nextCursor must be a string, got ${JSON.stringify(result.nextCursor)}`);
|
|
1371
|
+
}
|
|
1372
|
+
if (seen.has(result.nextCursor)) {
|
|
1373
|
+
throw new Error(`MCPL server "${this.id}" repeated tools/list cursor ${JSON.stringify(result.nextCursor)}`);
|
|
1374
|
+
}
|
|
1375
|
+
seen.add(result.nextCursor);
|
|
1376
|
+
cursor = result.nextCursor;
|
|
1377
|
+
}
|
|
1378
|
+
throw new Error(`MCPL server "${this.id}" tools/list did not finish within ${MAX_TOOL_LIST_PAGES} pages`);
|
|
837
1379
|
}
|
|
838
1380
|
/** Send `tools/call` and return the result. Optionally includes state/checkpoint for stateful tools. */
|
|
839
1381
|
sendToolsCall(name, args, stateParams) {
|
|
@@ -862,44 +1404,61 @@ export class McplServerConnection extends EventEmitter {
|
|
|
862
1404
|
// ==========================================================================
|
|
863
1405
|
// Lifecycle
|
|
864
1406
|
// ==========================================================================
|
|
865
|
-
/**
|
|
866
|
-
|
|
1407
|
+
/** The requests still waiting for an answer, for the operator's log line
|
|
1408
|
+
* when the connection goes: their methods and ids. */
|
|
1409
|
+
unansweredRequests() {
|
|
1410
|
+
const list = [...this.pendingRequests].map(([id, pending]) => `${pending.method} (id=${id})`).join(', ');
|
|
1411
|
+
return `${this.pendingRequests.size} request(s) unanswered: ${list}`;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Close the connection for good: disable reconnect, kill the child
|
|
1415
|
+
* process, and clean up resources. At once, as before, reconnecting stops
|
|
1416
|
+
* and pending requests fail; a reconnect handshake in flight is aborted.
|
|
1417
|
+
*
|
|
1418
|
+
* Calls share one attempt: while it is pending, and once it has
|
|
1419
|
+
* succeeded, close() returns it. The attempt waits for the aborted
|
|
1420
|
+
* reconnect's own cleanup, closes the transport (its close is the reaping
|
|
1421
|
+
* verdict) and retries each launch an earlier cleanup couldn't reap. It
|
|
1422
|
+
* rejects while any child can't be confirmed exited, with that failure
|
|
1423
|
+
* itself, or an AggregateError when there are several. A failed attempt is
|
|
1424
|
+
* cleared, so the next close() retries what it couldn't reap. 'close' is
|
|
1425
|
+
* emitted once, when the teardown of a connection the host closed while
|
|
1426
|
+
* live completes.
|
|
1427
|
+
*/
|
|
1428
|
+
close() {
|
|
867
1429
|
// Disable reconnect before closing — explicit close means stop retrying
|
|
868
1430
|
this.reconnectEnabled = false;
|
|
1431
|
+
this.closedByThisHost = true;
|
|
869
1432
|
if (this.reconnectTimer) {
|
|
870
1433
|
clearTimeout(this.reconnectTimer);
|
|
871
1434
|
this.reconnectTimer = null;
|
|
872
1435
|
}
|
|
1436
|
+
this.reconnecting?.abort.abort();
|
|
873
1437
|
if (this.closePromise)
|
|
874
1438
|
return this.closePromise;
|
|
875
|
-
if (this.closed
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1439
|
+
if (!this.closed) {
|
|
1440
|
+
this.closed = true;
|
|
1441
|
+
this.closeIncomplete = true;
|
|
1442
|
+
this.resetPolicyForTransportBoundary();
|
|
1443
|
+
// Reject all pending requests. Each message is the whole of what an
|
|
1444
|
+
// agent reads for a tools/call, and what follows a channel tool's own
|
|
1445
|
+
// words ("Failed to publish to channel: …") for the requests a channel
|
|
1446
|
+
// tool makes, so it names no method; the log line does. It doesn't say
|
|
1447
|
+
// whether this host reconnects: a restart closes the connection and then
|
|
1448
|
+
// connects again.
|
|
1449
|
+
if (this.pendingRequests.size > 0) {
|
|
1450
|
+
console.error(`MCPL server "${this.id}": this host closed its connection with ${this.unansweredRequests()}`);
|
|
1451
|
+
}
|
|
1452
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1453
|
+
if (pending.timer)
|
|
1454
|
+
clearTimeout(pending.timer);
|
|
1455
|
+
pending.reject(new McplRequestError(`The "${this.id}" server didn't answer before this host closed its connection, so whether the request took effect is unknown. ` +
|
|
1456
|
+
'Check its effect before trying again.', 'no-response'));
|
|
1457
|
+
}
|
|
1458
|
+
this.pendingRequests.clear();
|
|
1459
|
+
this.orphanedRequests.clear();
|
|
885
1460
|
}
|
|
886
|
-
this.
|
|
887
|
-
this.orphanedRequests.clear();
|
|
888
|
-
// Tear down the transport (kills the child / closes the socket). The
|
|
889
|
-
// transport's own 'close' event is short-circuited by `this.closed` in
|
|
890
|
-
// setupLifecycle, so this is the single source of the connection 'close'.
|
|
891
|
-
const attempt = Promise.resolve().then(async () => {
|
|
892
|
-
// An already opened handshake owns a transport too. It hands that
|
|
893
|
-
// transport back without admission once reconnect has been disabled.
|
|
894
|
-
// Only the registry's closeAll bounds its caller's wait for this; retry
|
|
895
|
-
// reuses this real close attempt.
|
|
896
|
-
if (this.reconnectPromise)
|
|
897
|
-
await this.reconnectPromise;
|
|
898
|
-
if (this.transport)
|
|
899
|
-
await this.transport.close();
|
|
900
|
-
this.closeIncomplete = false;
|
|
901
|
-
this.emit('close', null, null, this.closeInfoByHost());
|
|
902
|
-
});
|
|
1461
|
+
const attempt = this.closeStep(this.transport, this.unreaped.map((held) => held.transport));
|
|
903
1462
|
this.closePromise = attempt;
|
|
904
1463
|
void attempt.catch(() => {
|
|
905
1464
|
if (this.closePromise === attempt)
|
|
@@ -907,14 +1466,60 @@ export class McplServerConnection extends EventEmitter {
|
|
|
907
1466
|
});
|
|
908
1467
|
return attempt;
|
|
909
1468
|
}
|
|
1469
|
+
/** One close attempt. `heldBefore` are the launches earlier cleanups
|
|
1470
|
+
* couldn't reap, which it retries. */
|
|
1471
|
+
async closeStep(transport, heldBefore) {
|
|
1472
|
+
// An aborted reconnect cleans up its own launch, holding it if it can't.
|
|
1473
|
+
// That cleanup was this attempt's try at it, so its failure is this
|
|
1474
|
+
// attempt's, not retried here.
|
|
1475
|
+
await this.reconnecting?.done;
|
|
1476
|
+
const heldMeanwhile = this.unreaped.filter((held) => !heldBefore.includes(held.transport));
|
|
1477
|
+
const failures = heldMeanwhile.map((held) => held.failure);
|
|
1478
|
+
const targets = [];
|
|
1479
|
+
for (const target of [transport, ...heldBefore]) {
|
|
1480
|
+
if (target && !targets.includes(target) && !heldMeanwhile.some((held) => held.transport === target))
|
|
1481
|
+
targets.push(target);
|
|
1482
|
+
}
|
|
1483
|
+
for (const target of targets) {
|
|
1484
|
+
try {
|
|
1485
|
+
// Kills the child / closes the socket.
|
|
1486
|
+
await target.close();
|
|
1487
|
+
this.unreaped = this.unreaped.filter((held) => held.transport !== target);
|
|
1488
|
+
}
|
|
1489
|
+
catch (error) {
|
|
1490
|
+
const failure = errorOf(error);
|
|
1491
|
+
failures.push(failure);
|
|
1492
|
+
if (!this.unreaped.some((held) => held.transport === target))
|
|
1493
|
+
this.unreaped.push({ transport: target, failure });
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (failures.length === 1)
|
|
1497
|
+
throw failures[0];
|
|
1498
|
+
if (failures.length > 1) {
|
|
1499
|
+
throw new AggregateError(failures, `MCPL server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`);
|
|
1500
|
+
}
|
|
1501
|
+
// The transport's own 'close' event is short-circuited by `this.closed`
|
|
1502
|
+
// in setupLifecycle, so this is the single source of the connection
|
|
1503
|
+
// 'close' when the host tears down a live transport.
|
|
1504
|
+
if (this.closeIncomplete) {
|
|
1505
|
+
this.closeIncomplete = false;
|
|
1506
|
+
this.emit('close', null, null, this.closeInfoByHost());
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
910
1509
|
/**
|
|
911
1510
|
* Tear down a live transport after an awareness-accounting failure without
|
|
912
1511
|
* disabling configured auto-reconnect. The normal transport close handler
|
|
913
1512
|
* rejects requests, emits lifecycle state, and schedules the next bounded
|
|
914
|
-
* reconnect attempt. With reconnect disabled
|
|
1513
|
+
* reconnect attempt. With reconnect disabled the recycle ends the
|
|
1514
|
+
* connection for good, as close() does: 'close' comes once the child's
|
|
1515
|
+
* exit is confirmed, and a child it can't reap stays owned, holding the id.
|
|
915
1516
|
*/
|
|
916
1517
|
async reconnectAfterFailure() {
|
|
917
1518
|
this.pauseDataPlane();
|
|
1519
|
+
if (!this.reconnectEnabled) {
|
|
1520
|
+
await this.close();
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
918
1523
|
if (this.closed) {
|
|
919
1524
|
this.scheduleReconnect();
|
|
920
1525
|
return;
|
|
@@ -964,68 +1569,118 @@ export class McplServerConnection extends EventEmitter {
|
|
|
964
1569
|
* Attempt to reconnect by opening a fresh transport and performing the
|
|
965
1570
|
* handshake, then adopting that transport in place. Unlike the previous
|
|
966
1571
|
* approach this builds no throwaway connection instance, so there are no
|
|
967
|
-
* leaked listeners on a dead object.
|
|
1572
|
+
* leaked listeners on a dead object. The attempt is close()'s to abort,
|
|
1573
|
+
* and it settles only once its own launch is adopted, failed or cleaned up.
|
|
1574
|
+
* One attempt at a time: a request that arrives while one is in flight (a
|
|
1575
|
+
* recycle, say) starts nothing, since that attempt either adopts its
|
|
1576
|
+
* launch or schedules the next retry itself. A second attempt would take
|
|
1577
|
+
* close()'s ownership of the first (Iris-1827, room-293 #55410).
|
|
968
1578
|
*/
|
|
969
|
-
attemptReconnect() {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
this.
|
|
974
|
-
const clear = () => { if (this.reconnectPromise === attempt)
|
|
975
|
-
this.reconnectPromise = null; };
|
|
976
|
-
void attempt.then(clear, clear);
|
|
977
|
-
return attempt;
|
|
978
|
-
}
|
|
979
|
-
async reconnectOnce() {
|
|
980
|
-
// A queued recovery timer may outlive an earlier successful handshake.
|
|
981
|
-
// Recheck disconnection before opening or replacing any owned transport.
|
|
982
|
-
if (!this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities)
|
|
1579
|
+
async attemptReconnect() {
|
|
1580
|
+
// A queued recovery timer may outlive an earlier successful handshake:
|
|
1581
|
+
// only a connection that is down reconnects, so it never replaces a live
|
|
1582
|
+
// transport.
|
|
1583
|
+
if (this.reconnecting || !this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities)
|
|
983
1584
|
return;
|
|
1585
|
+
const abort = new AbortController();
|
|
1586
|
+
const done = this.reconnectOnce(this.config, this.hostCapabilities, abort.signal);
|
|
1587
|
+
this.reconnecting = { abort, done };
|
|
1588
|
+
await done;
|
|
1589
|
+
if (this.reconnecting?.done === done)
|
|
1590
|
+
this.reconnecting = null;
|
|
1591
|
+
}
|
|
1592
|
+
/** One reconnect attempt. It never rejects: each outcome is an event, a
|
|
1593
|
+
* scheduled retry, or a launch held as unreaped. */
|
|
1594
|
+
async reconnectOnce(config, hostCapabilities, signal) {
|
|
984
1595
|
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
985
1596
|
// reports attempt N (reconnectAttempts counts failures so far).
|
|
986
1597
|
const attempt = Math.max(1, this.reconnectAttempts);
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
});
|
|
1000
|
-
this.transport = transport;
|
|
1598
|
+
// The previous launch must be gone before another starts. A transport
|
|
1599
|
+
// the host closed itself (reconnectAfterFailure) may still be reaping its
|
|
1600
|
+
// child; one whose child already exited resolves at once.
|
|
1601
|
+
const previous = this.transport;
|
|
1602
|
+
if (previous) {
|
|
1603
|
+
try {
|
|
1604
|
+
await previous.close();
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
this.holdUnreaped(previous, errorOf(error));
|
|
1608
|
+
if (!signal.aborted)
|
|
1609
|
+
this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt, permanent: true });
|
|
1001
1610
|
return;
|
|
1002
1611
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
this.transport = transport;
|
|
1010
|
-
this.capabilities = capabilities;
|
|
1011
|
-
this.droppedCapabilities = droppedCapabilities;
|
|
1012
|
-
this.mcpToolsAdvertised = mcpToolsAdvertised;
|
|
1013
|
-
this.allowHostCommands = this.config?.allowHostCommands === true;
|
|
1014
|
-
this.closed = false;
|
|
1015
|
-
this.nextRequestId = 1;
|
|
1016
|
-
this.pendingRequests.clear();
|
|
1017
|
-
this.wireTransport(transport);
|
|
1018
|
-
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
1019
|
-
this.reconnectAttempts = 0;
|
|
1020
|
-
this.emit('reconnect', { attempts: attempt });
|
|
1612
|
+
}
|
|
1613
|
+
if (signal.aborted)
|
|
1614
|
+
return;
|
|
1615
|
+
let established;
|
|
1616
|
+
try {
|
|
1617
|
+
established = await McplServerConnection.handshake(config, hostCapabilities, signal);
|
|
1021
1618
|
}
|
|
1022
1619
|
catch (error) {
|
|
1023
|
-
|
|
1620
|
+
const unreaped = error instanceof HandshakeCleanupError;
|
|
1621
|
+
if (unreaped)
|
|
1622
|
+
this.holdUnreaped(error.transport, error.cleanup);
|
|
1623
|
+
// Closed by the host meanwhile: not a failed attempt.
|
|
1624
|
+
if (signal.aborted)
|
|
1625
|
+
return;
|
|
1626
|
+
const failure = unreaped ? error.failure : errorOf(error);
|
|
1627
|
+
const redact = (text) => redactServerText(text, this.config ?? {});
|
|
1628
|
+
console.error(`MCPL server "${this.id}" reconnect failed: ${redact(failure.message)}${withStderr(failure, redact)}`);
|
|
1024
1629
|
this.reconnectAttempts = attempt + 1;
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1630
|
+
// No common protocol revision: every further attempt would restart the
|
|
1631
|
+
// server to hear the same answer. Stop retrying; the configuration is
|
|
1632
|
+
// what has to change. A launch that couldn't be reaped halts retrying
|
|
1633
|
+
// for good: close() can confirm its exit, but close() is terminal, so
|
|
1634
|
+
// nothing resumes this connection. Every other failure keeps the
|
|
1635
|
+
// backoff loop.
|
|
1636
|
+
if (error instanceof McplProtocolVersionError)
|
|
1637
|
+
this.reconnectEnabled = false;
|
|
1638
|
+
const permanent = unreaped || error instanceof McplProtocolVersionError;
|
|
1639
|
+
const message = unreaped ? `${failure.message}; ${this.unreapedFailure()}` : failure.message;
|
|
1640
|
+
const stderrTail = stderrTailOf(failure);
|
|
1641
|
+
this.emit('reconnect-failed', { error: message, ...(stderrTail ? { stderrTail } : {}), attempt, ...(permanent ? { permanent: true } : {}) });
|
|
1642
|
+
if (!permanent)
|
|
1643
|
+
this.scheduleReconnect();
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = established;
|
|
1647
|
+
if (signal.aborted) {
|
|
1648
|
+
// Closed by the host while the handshake finished: this launch never
|
|
1649
|
+
// serves. close() is waiting on this attempt, so it hears the verdict.
|
|
1650
|
+
// The transport isn't wired, and the handshake has taken its own
|
|
1651
|
+
// listeners off, so an error it reports while it closes, such as a
|
|
1652
|
+
// malformed frame from a WebSocket server, is logged here rather than
|
|
1653
|
+
// thrown where nothing catches it.
|
|
1654
|
+
transport.on('error', (err) => {
|
|
1655
|
+
console.error(`[mcpl] ${this.id}: a transport closed before it served reported an error: ${redactServerText(err.message, config)}`);
|
|
1656
|
+
});
|
|
1657
|
+
await transport.close().catch((error) => this.holdUnreaped(transport, errorOf(error)));
|
|
1658
|
+
return;
|
|
1028
1659
|
}
|
|
1660
|
+
// Close both planes before the new transport can emit, as at initial
|
|
1661
|
+
// connect: its traffic is admitted against the grant, which this
|
|
1662
|
+
// boundary resets, so control traffic too (a server's channels/register
|
|
1663
|
+
// right after initialize) must wait for the new grant rather than be
|
|
1664
|
+
// refused against the empty one. The reconnect lifecycle event bypasses
|
|
1665
|
+
// buffering; its framework listener installs the awareness barrier,
|
|
1666
|
+
// re-establishes the grant (§5.3 policy responses are never buffered),
|
|
1667
|
+
// and then opens the planes.
|
|
1668
|
+
this.controlPlaneReady = false;
|
|
1669
|
+
this.dataPlaneReady = false;
|
|
1670
|
+
this.resetPolicyForTransportBoundary();
|
|
1671
|
+
this.transport = transport;
|
|
1672
|
+
this.capabilities = capabilities;
|
|
1673
|
+
this.droppedCapabilities = droppedCapabilities;
|
|
1674
|
+
this.mcpToolsAdvertised = mcpToolsAdvertised;
|
|
1675
|
+
this.protocolVersion = protocolVersion;
|
|
1676
|
+
this.allowHostCommands = this.config?.allowHostCommands === true;
|
|
1677
|
+
this.closed = false;
|
|
1678
|
+
this.nextRequestId = 1;
|
|
1679
|
+
this.pendingRequests.clear();
|
|
1680
|
+
this.wireTransport(transport);
|
|
1681
|
+
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
1682
|
+
this.reconnectAttempts = 0;
|
|
1683
|
+
this.emit('reconnect', { attempts: attempt });
|
|
1029
1684
|
}
|
|
1030
1685
|
// ==========================================================================
|
|
1031
1686
|
// Private: message sending
|
|
@@ -1043,7 +1698,29 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1043
1698
|
*/
|
|
1044
1699
|
sendRequest(method, params, options = {}) {
|
|
1045
1700
|
if (this.closed) {
|
|
1046
|
-
|
|
1701
|
+
// After this host's own close, which a restart makes before connecting
|
|
1702
|
+
// again and a removal makes for good, only that this host closed it
|
|
1703
|
+
// holds, and one next step holds for both: calling again reaches the
|
|
1704
|
+
// restarted server, or fails where it's gone.
|
|
1705
|
+
return Promise.reject(new McplRequestError(this.closedByThisHost
|
|
1706
|
+
? `The request wasn't sent: this host closed its connection to the "${this.id}" server, so nothing ran. ` +
|
|
1707
|
+
'If calling it again fails, ask whoever runs this host.'
|
|
1708
|
+
: `The request wasn't sent: the connection to the "${this.id}" server is closed, so nothing ran. ` +
|
|
1709
|
+
(this.willReconnect
|
|
1710
|
+
? 'This host is reconnecting to it; try again later.'
|
|
1711
|
+
: "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."), 'not-sent'));
|
|
1712
|
+
}
|
|
1713
|
+
// Once the child's stdin or stdout has failed, the transport is ending
|
|
1714
|
+
// it and writes nothing more to it, so a request is refused here as not
|
|
1715
|
+
// sent rather than left to wait out its timeout. The method and the
|
|
1716
|
+
// pipe's error are the operator's, so they go to the log.
|
|
1717
|
+
const pipeFailure = this.transport?.pipeFailure;
|
|
1718
|
+
if (pipeFailure) {
|
|
1719
|
+
console.error(`MCPL server "${this.id}": ${method} not sent, since its stdio pipe failed: ${logDetail(pipeFailure.message)}`);
|
|
1720
|
+
return Promise.reject(new McplRequestError(`The request wasn't sent: this host can no longer write to the "${this.id}" server, so nothing ran. ` +
|
|
1721
|
+
(this.willReconnect
|
|
1722
|
+
? 'This host is reconnecting to it; try again later.'
|
|
1723
|
+
: "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."), 'not-sent'));
|
|
1047
1724
|
}
|
|
1048
1725
|
const id = this.nextRequestId++;
|
|
1049
1726
|
const request = {
|
|
@@ -1075,12 +1752,14 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1075
1752
|
if (this.orphanedRequests.size > 4096)
|
|
1076
1753
|
this.orphanedRequests.delete(this.orphanedRequests.keys().next().value);
|
|
1077
1754
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1755
|
+
// The method and id are the operator's, so they go to the log.
|
|
1756
|
+
console.error(`MCPL server "${this.id}" did not respond to ${method} (id=${id}) within ${timeoutMs}ms; ` +
|
|
1757
|
+
'the request was abandoned here, not cancelled');
|
|
1758
|
+
const seconds = timeoutMs / 1000;
|
|
1759
|
+
reject(new McplRequestError(`The "${this.id}" server didn't answer within ${seconds} second${seconds === 1 ? '' : 's'}, ` +
|
|
1760
|
+
'so whether the request took effect is unknown: ' +
|
|
1761
|
+
'it was abandoned here, not cancelled, and may still complete on the server. ' +
|
|
1762
|
+
'Check its effect before trying again, since repeating something that changes state may do it twice.', 'no-response'));
|
|
1084
1763
|
}, timeoutMs);
|
|
1085
1764
|
// Don't hold the event loop open for the watchdog alone.
|
|
1086
1765
|
pending.timer.unref?.();
|
|
@@ -1091,11 +1770,13 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1091
1770
|
}
|
|
1092
1771
|
catch (err) {
|
|
1093
1772
|
// A synchronous write rejection sent no request. Remove its pending
|
|
1094
|
-
// state and watchdog before reporting that it is safe to retry.
|
|
1773
|
+
// state and watchdog before reporting that it is safe to retry. The
|
|
1774
|
+
// method, id and the transport's words are the operator's.
|
|
1095
1775
|
this.pendingRequests.delete(id);
|
|
1096
1776
|
if (pending.timer)
|
|
1097
1777
|
clearTimeout(pending.timer);
|
|
1098
|
-
|
|
1778
|
+
console.error(`MCPL server "${this.id}": writing ${method} (id=${id}) failed: ${logDetail(err instanceof Error ? err.message : err)}`);
|
|
1779
|
+
reject(new McplRequestError(`The request wasn't sent: this host couldn't write it to the "${this.id}" server, so nothing ran. Try again later.`, 'not-sent'));
|
|
1099
1780
|
}
|
|
1100
1781
|
});
|
|
1101
1782
|
}
|
|
@@ -1188,6 +1869,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1188
1869
|
* Wire up the transport's line stream to route incoming JSON-RPC messages.
|
|
1189
1870
|
*/
|
|
1190
1871
|
setupMessageRouting(transport) {
|
|
1872
|
+
// The boundary this transport serves within: a line read once it has
|
|
1873
|
+
// passed is the dead process's (afterBoundary).
|
|
1874
|
+
const epoch = this.transportEpoch;
|
|
1191
1875
|
transport.on('line', (line) => {
|
|
1192
1876
|
let msg;
|
|
1193
1877
|
try {
|
|
@@ -1202,7 +1886,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1202
1886
|
if (msg === null || typeof msg !== 'object' || Array.isArray(msg))
|
|
1203
1887
|
return;
|
|
1204
1888
|
try {
|
|
1205
|
-
this.dispatchMessage(msg);
|
|
1889
|
+
this.dispatchMessage(msg, epoch);
|
|
1206
1890
|
}
|
|
1207
1891
|
catch (error) {
|
|
1208
1892
|
const request = msg;
|
|
@@ -1214,14 +1898,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1214
1898
|
});
|
|
1215
1899
|
}
|
|
1216
1900
|
/** Contain synchronous dispatch failures; async RPC handlers own their promises. */
|
|
1217
|
-
dispatchMessage(msg) {
|
|
1901
|
+
dispatchMessage(msg, epoch) {
|
|
1218
1902
|
// Is this a response to one of our outbound requests?
|
|
1219
1903
|
if ('id' in msg && msg.id != null && !('method' in msg)) {
|
|
1220
|
-
this.handleResponse(msg);
|
|
1904
|
+
this.handleResponse(msg, epoch !== this.transportEpoch);
|
|
1221
1905
|
return;
|
|
1222
1906
|
}
|
|
1223
1907
|
// It is an inbound request or notification from the server
|
|
1224
1908
|
const request = msg;
|
|
1909
|
+
// Read after its transport's boundary, it is the dead process's: the
|
|
1910
|
+
// boundary's rule applies (afterBoundary).
|
|
1911
|
+
if (epoch !== this.transportEpoch) {
|
|
1912
|
+
this.afterBoundary(request, epoch);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1225
1915
|
// MCP's ping, which either side may send: its receiver answers promptly
|
|
1226
1916
|
// with an empty result. It's answered as it arrives, before admission and
|
|
1227
1917
|
// buffering, since it asks only whether the connection is alive, and a
|
|
@@ -1275,17 +1965,50 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1275
1965
|
// Emit the typed event with params and (for requests) a respond callback
|
|
1276
1966
|
if (request.id != null) {
|
|
1277
1967
|
// Server expects a response — provide a respond helper
|
|
1278
|
-
this.dispatchInboundEvent(eventName, request.params,
|
|
1279
|
-
id: request.id,
|
|
1280
|
-
respond: (result) => this.sendResponse(request.id, result),
|
|
1281
|
-
respondError: (code, message, data) => this.sendErrorResponse(request.id, code, message, data),
|
|
1282
|
-
});
|
|
1968
|
+
this.dispatchInboundEvent(eventName, request.params, this.responderFor(request.id, epoch));
|
|
1283
1969
|
}
|
|
1284
1970
|
else {
|
|
1285
1971
|
// Notification — no response expected
|
|
1286
1972
|
this.dispatchInboundEvent(eventName, request.params);
|
|
1287
1973
|
}
|
|
1288
1974
|
}
|
|
1975
|
+
/**
|
|
1976
|
+
* A request's responder, bound to the transport it came on (`epoch`): an
|
|
1977
|
+
* answer, or a streamed inference's chunk, sent after that transport has
|
|
1978
|
+
* closed reaches nothing, never a server that didn't ask it.
|
|
1979
|
+
*/
|
|
1980
|
+
responderFor(id, epoch) {
|
|
1981
|
+
return {
|
|
1982
|
+
id,
|
|
1983
|
+
respond: (result) => this.sendResponse(id, result, epoch),
|
|
1984
|
+
respondError: (code, message, data) => this.sendErrorResponse(id, code, message, data, epoch),
|
|
1985
|
+
sendChunk: (params) => this.sendInferenceChunk(params, epoch),
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* A request or notification read from a transport after its boundary has
|
|
1990
|
+
* passed: the boundary's rule (resetPolicyForTransportBoundary). An
|
|
1991
|
+
* incoming message or push is held as kept, for the next transport's
|
|
1992
|
+
* grant, and any other is dropped and logged. (A response read then goes to
|
|
1993
|
+
* handleResponse, flagged late.) A stdio
|
|
1994
|
+
* server's transport closes on the child's 'exit', when its stdout may
|
|
1995
|
+
* still be open, as it is while a process the server started holds it, and
|
|
1996
|
+
* it is read until the next reconnect attempt closes its reader. No
|
|
1997
|
+
* transport serves meanwhile, so what is held waits for the next grant
|
|
1998
|
+
* (flushHeld). A connection closed for good, which won't reconnect, reads
|
|
1999
|
+
* it while its writer lives, with no grant to come, so it drops and logs
|
|
2000
|
+
* all of it; hold's bounds cap what any other keeps.
|
|
2001
|
+
*/
|
|
2002
|
+
afterBoundary(request, epoch) {
|
|
2003
|
+
const event = typeof request.method === 'string' && Object.hasOwn(McplServerConnection.METHOD_TO_EVENT, request.method)
|
|
2004
|
+
? McplServerConnection.METHOD_TO_EVENT[request.method] : undefined;
|
|
2005
|
+
if (this.closedForGood || event === undefined || !McplServerConnection.WORLD_EVENTS.has(event)) {
|
|
2006
|
+
console.error(`[mcpl] ${this.id}: dropped a message read after its transport closed` +
|
|
2007
|
+
`${this.closedForGood ? ', with no transport to come' : ''}: ${describeMethod(request.method)}`);
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
this.hold(event, request.id != null ? [request.params, this.responderFor(request.id, epoch)] : [request.params], true);
|
|
2011
|
+
}
|
|
1289
2012
|
/** Live delivery and delayed plane flushes share the same containment boundary. */
|
|
1290
2013
|
dispatchInboundEvent(event, ...args) {
|
|
1291
2014
|
try {
|
|
@@ -1337,8 +2060,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1337
2060
|
/**
|
|
1338
2061
|
* Handle a JSON-RPC response by resolving/rejecting the corresponding pending request.
|
|
1339
2062
|
*/
|
|
1340
|
-
handleResponse(response) {
|
|
1341
|
-
|
|
2063
|
+
handleResponse(response, late = false) {
|
|
2064
|
+
// One read after its transport's boundary answers a request the close
|
|
2065
|
+
// ended. It's matched against none of this connection's request records,
|
|
2066
|
+
// since the next transport numbers its requests afresh, and goes the way
|
|
2067
|
+
// of any late answer: a stateful one is surfaced as an orphaned response.
|
|
2068
|
+
const pending = late ? undefined : this.pendingRequests.get(response.id);
|
|
1342
2069
|
if (!pending) {
|
|
1343
2070
|
// Orphaned response — the request already timed out (or was settled) and
|
|
1344
2071
|
// was removed from pendingRequests. A late STATEFUL result must be
|
|
@@ -1346,8 +2073,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1346
2073
|
// Selected side-effecting calls are also surfaced even without state so
|
|
1347
2074
|
// their outcome-unknown receipt remains visible. We cannot safely
|
|
1348
2075
|
// re-inject either result because the original dispatch context is gone.
|
|
1349
|
-
const orphaned = this.orphanedRequests.get(response.id);
|
|
1350
|
-
|
|
2076
|
+
const orphaned = late ? undefined : this.orphanedRequests.get(response.id);
|
|
2077
|
+
if (!late)
|
|
2078
|
+
this.orphanedRequests.delete(response.id);
|
|
1351
2079
|
// A push/render answered after its timeout (RFC-006 §5.3) is reported
|
|
1352
2080
|
// with the render's featureSet, key, eventId and, for a channel-scoped
|
|
1353
2081
|
// subject, channelId, kept from its request when the timeout fired, so
|
|
@@ -1389,14 +2117,27 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1389
2117
|
if (pending.timer)
|
|
1390
2118
|
clearTimeout(pending.timer);
|
|
1391
2119
|
if (response.error) {
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
|
|
2120
|
+
// The server's own words, after a word on whose they are, bounded as
|
|
2121
|
+
// errors.ts says, since they're the server's text. Only a string is
|
|
2122
|
+
// words: a message of any other JSON value reads as none. The method,
|
|
2123
|
+
// id and JSON-RPC code are the operator's, so they go to the log, which
|
|
2124
|
+
// prints any value a server can send without throwing (logValue). The
|
|
2125
|
+
// caller's text keeps the server's words unredacted, as a JSON-RPC
|
|
2126
|
+
// answer passes (redactServerText's scope); the log is a record of the
|
|
2127
|
+
// server's text, so the message is redacted there, whatever JSON value
|
|
2128
|
+
// it is, as every other line this connection writes is.
|
|
2129
|
+
const said = typeof response.error.message === 'string' ? boundedForLog(response.error.message.trim()) : '';
|
|
2130
|
+
// The call is settled before its log line: its entry is gone and its
|
|
2131
|
+
// timer cleared, so nothing else would settle it, and a host's logger
|
|
2132
|
+
// can throw. The dispatch's containment reports such a throw. (A
|
|
2133
|
+
// configuration that can't be read withholds the message rather than
|
|
2134
|
+
// throw: redactServerText.)
|
|
2135
|
+
pending.reject(new McplRequestError(said
|
|
2136
|
+
? `The "${this.id}" server answered with an error: ${said}`
|
|
2137
|
+
: `The "${this.id}" server answered with an error without saying what went wrong, so whether the request took effect is unknown. ` +
|
|
2138
|
+
'Check its effect before trying again.', 'error-response', response.error.data));
|
|
2139
|
+
console.error(`MCPL server "${this.id}" answered ${pending.method} (id=${response.id}) with error ${logValue(response.error.code)}: ` +
|
|
2140
|
+
logValueRedacted(response.error.message, (text) => redactServerText(text, this.config ?? {})));
|
|
1400
2141
|
}
|
|
1401
2142
|
else {
|
|
1402
2143
|
pending.resolve(response.result);
|
|
@@ -1405,17 +2146,30 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1405
2146
|
/**
|
|
1406
2147
|
* Send a successful JSON-RPC response back to the server.
|
|
1407
2148
|
*/
|
|
1408
|
-
sendResponse(id, result) {
|
|
1409
|
-
|
|
2149
|
+
sendResponse(id, result, epoch = this.transportEpoch) {
|
|
2150
|
+
// A closed connection's epoch is past every request's, so a refusal
|
|
2151
|
+
// while it is closed is logged too.
|
|
2152
|
+
if (!this.sameTransport(id, epoch) || this.closed)
|
|
1410
2153
|
return;
|
|
1411
2154
|
const response = { jsonrpc: '2.0', id, result };
|
|
1412
2155
|
this.transport?.writeLine(JSON.stringify(response));
|
|
1413
2156
|
}
|
|
2157
|
+
/** Whether a response to a request received on `epoch` may go out now.
|
|
2158
|
+
* The id is the server's, so it's logged as a field; `answer`, which the
|
|
2159
|
+
* caller has formatted for the log, ends the line. */
|
|
2160
|
+
sameTransport(id, epoch, answer) {
|
|
2161
|
+
if (epoch === this.transportEpoch)
|
|
2162
|
+
return true;
|
|
2163
|
+
console.error(`[mcpl] ${this.id}: not answering request ${logValue(id)}: the transport it came on has closed${answer ? `, with ${answer}` : ''}`);
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
1414
2166
|
/**
|
|
1415
2167
|
* Send a JSON-RPC error response back to the server.
|
|
1416
2168
|
*/
|
|
1417
|
-
sendErrorResponse(id, code, message, data) {
|
|
1418
|
-
|
|
2169
|
+
sendErrorResponse(id, code, message, data, epoch = this.transportEpoch) {
|
|
2170
|
+
// A refusal that can't be answered still says what it refused: a held
|
|
2171
|
+
// message admitted under the next transport's grant is refused here.
|
|
2172
|
+
if (!this.sameTransport(id, epoch, `error ${code} ${logDetail(message)}`) || this.closed)
|
|
1419
2173
|
return;
|
|
1420
2174
|
const response = {
|
|
1421
2175
|
jsonrpc: '2.0',
|
|
@@ -1439,24 +2193,37 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1439
2193
|
if (!this.emitToErrorListeners(error))
|
|
1440
2194
|
console.error(`[mcpl] ${this.id}: transport error: ${logDetail(err.message)}`);
|
|
1441
2195
|
});
|
|
1442
|
-
|
|
2196
|
+
const lost = (info) => {
|
|
1443
2197
|
if (!this.closed) {
|
|
1444
2198
|
this.closed = true;
|
|
1445
2199
|
this.resetPolicyForTransportBoundary();
|
|
1446
|
-
//
|
|
1447
|
-
|
|
2200
|
+
// A teardown the host asked for via reconnectAfterFailure() arrives
|
|
2201
|
+
// here too; it's reported as such below.
|
|
2202
|
+
const byHost = this.hostCloseRequested;
|
|
2203
|
+
this.hostCloseRequested = false;
|
|
2204
|
+
// Reject all pending requests. The words hold whether the server
|
|
2205
|
+
// dropped or this host closed the transport to recover; how it closed
|
|
2206
|
+
// is the operator's, so it goes to the log, not into the messages.
|
|
2207
|
+
if (this.pendingRequests.size > 0) {
|
|
2208
|
+
// Whatever the host asked for, the close's own details are kept, so
|
|
2209
|
+
// a drop that races a recycle loses nothing.
|
|
2210
|
+
const how = `${byHost ? 'closed by this host, ' : ''}` +
|
|
2211
|
+
`code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${closeReasonText(info)}`;
|
|
2212
|
+
console.error(`MCPL server "${this.id}" disconnected (${how}) with ${this.unansweredRequests()}`);
|
|
2213
|
+
}
|
|
2214
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1448
2215
|
if (pending.timer)
|
|
1449
2216
|
clearTimeout(pending.timer);
|
|
1450
|
-
pending.reject(new McplRequestError(`
|
|
2217
|
+
pending.reject(new McplRequestError(`The connection to the "${this.id}" server closed before it answered, so whether the request took effect is unknown. ` +
|
|
2218
|
+
(this.reconnectEnabled
|
|
2219
|
+
? 'Check its effect before trying again; this host is reconnecting to it.'
|
|
2220
|
+
: "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."), 'no-response'));
|
|
1451
2221
|
}
|
|
1452
2222
|
this.pendingRequests.clear();
|
|
1453
2223
|
this.orphanedRequests.clear();
|
|
1454
2224
|
// Forward the transport's close info so listeners can tell a server
|
|
1455
2225
|
// that refused the credential (WebSocket 4001) from a crash, and act
|
|
1456
|
-
// before the reconnect below redials.
|
|
1457
|
-
// via reconnectAfterFailure() arrives here too; report it as such.
|
|
1458
|
-
const byHost = this.hostCloseRequested;
|
|
1459
|
-
this.hostCloseRequested = false;
|
|
2226
|
+
// before the reconnect below redials.
|
|
1460
2227
|
const closeInfo = {
|
|
1461
2228
|
transport: transport.kind,
|
|
1462
2229
|
code: info.code ?? null,
|
|
@@ -1471,6 +2238,22 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1471
2238
|
this.scheduleReconnect();
|
|
1472
2239
|
}
|
|
1473
2240
|
}
|
|
2241
|
+
};
|
|
2242
|
+
transport.on('close', lost);
|
|
2243
|
+
// A stdio child whose stdin or stdout failed, and that outlived SIGKILL
|
|
2244
|
+
// too: the transport stays open, owning the child. The connection holds
|
|
2245
|
+
// it as a launch it can't reap, as a reconnect does, which halts
|
|
2246
|
+
// reconnecting, and is lost as on a close, with the permanent failure
|
|
2247
|
+
// that raises the alert and takes its tools off the list. Its id stays
|
|
2248
|
+
// held until close() confirms the exit. A close the host has begun
|
|
2249
|
+
// reports the failure itself. During a handshake nothing listens yet:
|
|
2250
|
+
// the handshake's own cleanup holds the launch, once the handshake fails.
|
|
2251
|
+
transport.on('unreaped', (failure) => {
|
|
2252
|
+
if (this.closed || this.transport !== transport)
|
|
2253
|
+
return;
|
|
2254
|
+
this.holdUnreaped(transport, failure);
|
|
2255
|
+
lost({ reason: `its stdio pipe failed with ${transport.pipeFailure?.message ?? 'an error'}, and its child outlived SIGKILL` });
|
|
2256
|
+
this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt: Math.max(1, this.reconnectAttempts), permanent: true });
|
|
1474
2257
|
});
|
|
1475
2258
|
}
|
|
1476
2259
|
}
|