@animalabs/agent-framework 0.22.4 → 0.23.1
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 -4
- 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
|
@@ -15,7 +15,13 @@ import { openTransport, STDERR_END_WAIT_MS, type McplTransport, type TransportCl
|
|
|
15
15
|
import { maskNegotiatedCapabilities } from './capability-mask.js';
|
|
16
16
|
import { CapabilityGrant, expandAdvertisementShorthand } from './capability-grant.js';
|
|
17
17
|
import { CAPABILITY_DISABLED, boundedForLog, describeCause } from './errors.js';
|
|
18
|
-
import { logDetail, logValue } from './log-value.js';
|
|
18
|
+
import { logDetail, logValue, logValueRedacted } from './log-value.js';
|
|
19
|
+
import {
|
|
20
|
+
LEGACY_MCP_PROTOCOL_VERSION,
|
|
21
|
+
MODERN_MCP_PROTOCOL_VERSION,
|
|
22
|
+
checkServerConfig,
|
|
23
|
+
redactServerText,
|
|
24
|
+
} from './protocol-family.js';
|
|
19
25
|
|
|
20
26
|
import type {
|
|
21
27
|
McplServerConfig,
|
|
@@ -86,24 +92,26 @@ export function stderrTailOf(error: unknown): string | undefined {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/** For the host's own log line about a failed connect: the server's stderr
|
|
89
|
-
* that the error keeps as `stderrTail`,
|
|
90
|
-
*
|
|
95
|
+
* that the error keeps as `stderrTail`, with the server's credentials
|
|
96
|
+
* taken out by `redact` (its redactServerText), one marked line per line of
|
|
97
|
+
* it, each through log-value, since it's the server's text. '' when there's
|
|
91
98
|
* none. */
|
|
92
|
-
export function withStderr(error: unknown): string {
|
|
99
|
+
export function withStderr(error: unknown, redact: (text: string) => string): string {
|
|
93
100
|
const tail = stderrTailOf(error);
|
|
94
101
|
if (tail === undefined) return '';
|
|
95
|
-
return '. The last of its stderr:' + tail.split('\n').map((line) => `\n stderr: ${logDetail(line)}`).join('');
|
|
102
|
+
return '. The last of its stderr:' + redact(tail).split('\n').map((line) => `\n stderr: ${logDetail(line)}`).join('');
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
/** A transport close's reason, as
|
|
99
|
-
* own words. A WebSocket peer's close reason is the
|
|
100
|
-
* (`reasonFromPeer`), so
|
|
101
|
-
* close without one, as `mcpl:server-closed` leaves that text out. A
|
|
105
|
+
/** A transport close's reason, as the host's log and the handshake's error
|
|
106
|
+
* give it: the transport's own words. A WebSocket peer's close reason is the
|
|
107
|
+
* server's text (`reasonFromPeer`), so they give the words the transport uses
|
|
108
|
+
* for a close without one, as `mcpl:server-closed` leaves that text out. A
|
|
102
109
|
* WebSocket close's code, a number, says what the server meant (4001: a
|
|
103
|
-
* refused credential), with or without a reason, so each
|
|
104
|
-
* a
|
|
105
|
-
* WebSocket close, since a close before the
|
|
106
|
-
* 'close' event. A stdio child's exit reads
|
|
110
|
+
* refused credential), with or without a reason, so each gives it too: the
|
|
111
|
+
* log line for a close with requests pending has `code=`, and the
|
|
112
|
+
* handshake's error adds it for any WebSocket close, since a close before the
|
|
113
|
+
* handshake completes reaches no 'close' event. A stdio child's exit reads
|
|
114
|
+
* as before. */
|
|
107
115
|
function closeReasonText(info: TransportCloseInfo): string {
|
|
108
116
|
if (info.reasonFromPeer) return 'websocket closed';
|
|
109
117
|
return info.reason ?? 'unknown';
|
|
@@ -116,8 +124,113 @@ function closeReasonText(info: TransportCloseInfo): string {
|
|
|
116
124
|
* tool work while still bounding the hang. */
|
|
117
125
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
118
126
|
|
|
119
|
-
/** MCP protocol version used in the initialize handshake
|
|
120
|
-
|
|
127
|
+
/** MCP protocol version used in the initialize handshake: the only revision
|
|
128
|
+
* this legacy engine offers or accepts. */
|
|
129
|
+
const MCP_PROTOCOL_VERSION = LEGACY_MCP_PROTOCOL_VERSION;
|
|
130
|
+
|
|
131
|
+
/** JSON-RPC code for an unsupported protocol version (MCP 2026-07-28 §basic,
|
|
132
|
+
* the code a modern-only server answers our `initialize` with). */
|
|
133
|
+
const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
|
|
134
|
+
|
|
135
|
+
/** Upper bound on `tools/list` pages, against a server whose cursors never
|
|
136
|
+
* end. Generous: real inventories fit in a handful of pages. */
|
|
137
|
+
const MAX_TOOL_LIST_PAGES = 100;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The server and this engine have no protocol revision in common, so the
|
|
141
|
+
* connection was never established. This is a protocol-negotiation fact, not
|
|
142
|
+
* a request outcome. Retrying cannot change it: each engine offers one
|
|
143
|
+
* revision, and only configuration can fix the mismatch. A connection that
|
|
144
|
+
* gets this error does not schedule reconnects. The legacy engine and the
|
|
145
|
+
* modern one (ModernMcpConnection, whose pinned revision the server refused)
|
|
146
|
+
* both throw it, so its readers treat them alike.
|
|
147
|
+
*
|
|
148
|
+
* - `rejected`: the server refused the offered revision: the legacy engine's
|
|
149
|
+
* `initialize` answered with error -32022, or a modern server's discovery
|
|
150
|
+
* or error naming no common revision. `supported` is the server's own list,
|
|
151
|
+
* when it gave one.
|
|
152
|
+
* - `mismatch`: the server answered the legacy engine's `initialize` with a
|
|
153
|
+
* revision other than 2024-11-05, or with none.
|
|
154
|
+
*/
|
|
155
|
+
export class McplProtocolVersionError extends Error {
|
|
156
|
+
constructor(
|
|
157
|
+
message: string,
|
|
158
|
+
readonly kind: 'rejected' | 'mismatch',
|
|
159
|
+
/** Revision the server answered with (`mismatch`; null when it gave none). */
|
|
160
|
+
readonly returned: string | null,
|
|
161
|
+
/** Revisions the server says it supports (`rejected`, when stated). */
|
|
162
|
+
readonly supported: readonly string[] | null,
|
|
163
|
+
/** The JSON-RPC error's code and data, verbatim (`rejected`). */
|
|
164
|
+
readonly code?: number,
|
|
165
|
+
readonly data?: unknown,
|
|
166
|
+
/** The revision this client offered: the legacy engine's, unless a
|
|
167
|
+
* modern connection's. */
|
|
168
|
+
readonly offered: string = MCP_PROTOCOL_VERSION,
|
|
169
|
+
) {
|
|
170
|
+
super(message);
|
|
171
|
+
this.name = 'McplProtocolVersionError';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function protocolVersionRejection(id: string, error: { code: number; message: string; data?: unknown }): McplProtocolVersionError {
|
|
176
|
+
const data = error.data as { supported?: unknown } | undefined;
|
|
177
|
+
const supported = Array.isArray(data?.supported) && data.supported.every((v) => typeof v === 'string')
|
|
178
|
+
? (data.supported as string[])
|
|
179
|
+
: null;
|
|
180
|
+
const modernHint = supported?.includes(MODERN_MCP_PROTOCOL_VERSION)
|
|
181
|
+
? ` It speaks modern MCP: configure it with protocol: 'modern' (stdio) or reach it over http(s).`
|
|
182
|
+
: '';
|
|
183
|
+
return new McplProtocolVersionError(
|
|
184
|
+
`MCPL server "${id}" does not support MCP ${MCP_PROTOCOL_VERSION}` +
|
|
185
|
+
// The server's message can be any JSON value, so it's read without
|
|
186
|
+
// throwing (describeCause) and bounded, as the initialize error is.
|
|
187
|
+
(supported ? ` (it supports: ${supported.join(', ') || 'none listed'}).` : ` (${boundedForLog(describeCause(error.message))}).`) +
|
|
188
|
+
modernHint,
|
|
189
|
+
'rejected',
|
|
190
|
+
null,
|
|
191
|
+
supported,
|
|
192
|
+
error.code,
|
|
193
|
+
error.data,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const errorOf = (value: unknown): Error => (value instanceof Error ? value : new Error(String(value)));
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A handshake that failed, and whose cleanup couldn't confirm that the child
|
|
201
|
+
* exited. The transport goes to whoever owns the launch.
|
|
202
|
+
*/
|
|
203
|
+
class HandshakeCleanupError extends Error {
|
|
204
|
+
constructor(
|
|
205
|
+
readonly failure: Error,
|
|
206
|
+
readonly cleanup: Error,
|
|
207
|
+
readonly transport: McplTransport,
|
|
208
|
+
) {
|
|
209
|
+
super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure });
|
|
210
|
+
this.name = 'HandshakeCleanupError';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A connect that failed, and whose cleanup couldn't confirm that the child
|
|
216
|
+
* exited: it may still be running. `connection` owns that launch, closed and
|
|
217
|
+
* not reconnecting. Its close() retries the reap and resolves once the exit
|
|
218
|
+
* is confirmed, so the caller can still clean up, and should start nothing
|
|
219
|
+
* else for this server until then. connect() and connectWithReconnect()
|
|
220
|
+
* throw this, and the modern engine's ModernMcpConnection.connect() too.
|
|
221
|
+
* `cause` is the connect's own failure.
|
|
222
|
+
*/
|
|
223
|
+
export class McplUnreapedLaunchError<Connection extends { close(): Promise<void> } = McplServerConnection> extends Error {
|
|
224
|
+
constructor(
|
|
225
|
+
readonly connection: Connection,
|
|
226
|
+
failure: Error,
|
|
227
|
+
/** The cleanup's own failure: the child it couldn't confirm exited. */
|
|
228
|
+
readonly cleanup: Error,
|
|
229
|
+
) {
|
|
230
|
+
super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure });
|
|
231
|
+
this.name = 'McplUnreapedLaunchError';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
121
234
|
|
|
122
235
|
/**
|
|
123
236
|
* Why a request to an MCPL server produced no result, as far as the host can
|
|
@@ -164,6 +277,14 @@ interface RequestOptions {
|
|
|
164
277
|
surfaceOrphanedResponse?: boolean;
|
|
165
278
|
}
|
|
166
279
|
|
|
280
|
+
/** A server→host message held while its plane is closed. `kept`: it came on
|
|
281
|
+
* a transport that is gone, and the boundary kept it (keepAcrossBoundary). */
|
|
282
|
+
interface HeldItem {
|
|
283
|
+
event: string;
|
|
284
|
+
args: unknown[];
|
|
285
|
+
kept?: true;
|
|
286
|
+
}
|
|
287
|
+
|
|
167
288
|
/** A request's method for a log line, whatever the server sent there. */
|
|
168
289
|
function describeMethod(method: unknown): string {
|
|
169
290
|
return typeof method === 'string'
|
|
@@ -192,7 +313,12 @@ function describeMethod(method: unknown): string {
|
|
|
192
313
|
* server's stderr when its handshake failed by its transport closing, a
|
|
193
314
|
* transport error or its timeout and it had written any; `error` never
|
|
194
315
|
* carries it.
|
|
195
|
-
* - `'reconnect'` — Background reconnect succeeded
|
|
316
|
+
* - `'reconnect'` — Background reconnect succeeded. The new transport
|
|
317
|
+
* starts with an empty grant and both planes
|
|
318
|
+
* closed, as at initial connect: whoever owns the
|
|
319
|
+
* connection runs the §5.3 policy exchange again,
|
|
320
|
+
* then opens the planes (the framework's listener
|
|
321
|
+
* does).
|
|
196
322
|
*/
|
|
197
323
|
export class McplServerConnection extends EventEmitter {
|
|
198
324
|
/** Unique server identifier from config. */
|
|
@@ -212,6 +338,10 @@ export class McplServerConnection extends EventEmitter {
|
|
|
212
338
|
* the sole source of the `tools` capability path). */
|
|
213
339
|
mcpToolsAdvertised = false;
|
|
214
340
|
|
|
341
|
+
/** The MCP revision established by the last successful handshake (always
|
|
342
|
+
* 2024-11-05 for this engine), or null before the first one. */
|
|
343
|
+
protocolVersion: string | null = null;
|
|
344
|
+
|
|
215
345
|
/** Host-owned per-server authority for host/command (config
|
|
216
346
|
* allowHostCommands, default false). No capability path exists and the
|
|
217
347
|
* grant cannot confer it — server params never decide admin authority. */
|
|
@@ -279,6 +409,22 @@ export class McplServerConnection extends EventEmitter {
|
|
|
279
409
|
this.grant = CapabilityGrant.empty();
|
|
280
410
|
this.policyEstablished = false;
|
|
281
411
|
this.transportEpoch++;
|
|
412
|
+
// What the server sent that is still held came on the transport that is
|
|
413
|
+
// now gone. What it said about its own process is dropped: its requests
|
|
414
|
+
// wait on answers that can no longer reach whoever asked (an inference
|
|
415
|
+
// request, a host/command), and its announcements describe a process
|
|
416
|
+
// that is gone (a tools/observe filter, a channel registration), so the
|
|
417
|
+
// next one announces its own. What it reported about the world is kept: a
|
|
418
|
+
// channel's incoming messages and its pushes, which the next process
|
|
419
|
+
// doesn't send again when its bridge records them as forwarded first, as
|
|
420
|
+
// discord-mcpl does with its watermark. They are admitted at the flush
|
|
421
|
+
// against the next transport's grant, like anything held, and an answer
|
|
422
|
+
// to one is never written to the next transport (sameTransport); a
|
|
423
|
+
// connection closed for good, with no next transport, drops them. The
|
|
424
|
+
// host's own reports about the transport stay too: its lifecycle
|
|
425
|
+
// (connect-failed, reconnect-failed), the server's stderr, and
|
|
426
|
+
// orphaned-response audits.
|
|
427
|
+
this.replaceHeld(this.keepAcrossBoundary(this.bufferedEvents));
|
|
282
428
|
// RFC-007 §6.5: a filter is per connection epoch — after a reconnect the
|
|
283
429
|
// server sends it again, and until then receives metadata only.
|
|
284
430
|
this.toolObserveFilter = null;
|
|
@@ -289,6 +435,59 @@ export class McplServerConnection extends EventEmitter {
|
|
|
289
435
|
this.manifestState = { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null };
|
|
290
436
|
}
|
|
291
437
|
|
|
438
|
+
/** What of these held items a transport boundary keeps (see
|
|
439
|
+
* resetPolicyForTransportBoundary), in order, each marked `kept`; the
|
|
440
|
+
* rest is logged and dropped. A connection closed for good has no next
|
|
441
|
+
* transport whose grant could admit what the server reported about the
|
|
442
|
+
* world, so that is dropped too. */
|
|
443
|
+
private keepAcrossBoundary(held: HeldItem[]): HeldItem[] {
|
|
444
|
+
const forGood = this.closedForGood;
|
|
445
|
+
const drops = (item: HeldItem) => McplServerConnection.PROCESS_EVENTS.has(item.event)
|
|
446
|
+
|| (forGood && McplServerConnection.WORLD_EVENTS.has(item.event));
|
|
447
|
+
const kept = held.filter((item) => !drops(item));
|
|
448
|
+
if (kept.length < held.length) {
|
|
449
|
+
// Named by event, with a count, so a dropped request a server was
|
|
450
|
+
// waiting on, which no answer will now reach, can be told apart.
|
|
451
|
+
const counts = new Map<string, number>();
|
|
452
|
+
for (const item of held) if (drops(item)) counts.set(item.event, (counts.get(item.event) ?? 0) + 1);
|
|
453
|
+
const what = [...counts].map(([event, count]) => `${count} ${logValue(event)}`).join(', ');
|
|
454
|
+
console.error(
|
|
455
|
+
`[mcpl] ${this.id}: dropped ${held.length - kept.length} held message(s) from a transport that closed` +
|
|
456
|
+
`${forGood ? ', with no transport to come' : ''}: ${what}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return kept.map((item) => ({ ...item, kept: true as const }));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Hold these items in place of what is held, with heldRequests and
|
|
463
|
+
* keptRequests counting the requests among them: a boundary keeps some of
|
|
464
|
+
* what was held, a flush that crosses one holds the rest of its snapshot
|
|
465
|
+
* again, and a flush starts from nothing held. Notifications need no cap
|
|
466
|
+
* here: hold caps them kept or not, and what this is given never has more
|
|
467
|
+
* than was held. */
|
|
468
|
+
private replaceHeld(items: HeldItem[]): void {
|
|
469
|
+
// The kept bound holds here too: boundaries that come with no flush
|
|
470
|
+
// between them each keep what the next transport held, so it's checked
|
|
471
|
+
// where they're joined. The earliest kept are kept, as at hold.
|
|
472
|
+
let kept = 0;
|
|
473
|
+
let past = 0;
|
|
474
|
+
this.bufferedEvents = items.filter((item) => {
|
|
475
|
+
if (item.kept !== true || !McplServerConnection.hasResponder(item)) return true;
|
|
476
|
+
if (kept < McplServerConnection.MAX_HELD_REQUESTS) { kept++; return true; }
|
|
477
|
+
past++;
|
|
478
|
+
return false;
|
|
479
|
+
});
|
|
480
|
+
this.heldRequests = this.bufferedEvents.filter((item) => !item.kept && McplServerConnection.hasResponder(item)).length;
|
|
481
|
+
this.keptRequests = kept;
|
|
482
|
+
if (past > 0) {
|
|
483
|
+
this.droppedKeptRequests += past;
|
|
484
|
+
console.error(
|
|
485
|
+
`[mcpl] ${this.id}: kept-request bound (${McplServerConnection.MAX_HELD_REQUESTS}) — ` +
|
|
486
|
+
`dropped ${past} request(s) a closed transport left`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
292
491
|
/** The active transport (stdio child or WebSocket). Null for a disconnected
|
|
293
492
|
* stub awaiting its first background reconnect. */
|
|
294
493
|
private transport: McplTransport | null;
|
|
@@ -311,9 +510,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
311
510
|
/** Set by `reconnectAfterFailure()` before it closes a live transport, so
|
|
312
511
|
* the transport close handler reports that teardown as `byHost: true`. */
|
|
313
512
|
private hostCloseRequested = false;
|
|
513
|
+
/** Set by `close()`: this host has stopped the connection, as a removal and
|
|
514
|
+
* a restart both do, so a request after it can't say whether the server
|
|
515
|
+
* comes back. */
|
|
516
|
+
private closedByThisHost = false;
|
|
517
|
+
/** The host's close attempt: in flight, or done. A failed one is cleared,
|
|
518
|
+
* so the next close() tries again. */
|
|
314
519
|
private closePromise: Promise<void> | null = null;
|
|
520
|
+
/** The host closed this connection while it was live, and its teardown
|
|
521
|
+
* hasn't completed yet: 'close' is emitted once it has. */
|
|
315
522
|
private closeIncomplete = false;
|
|
316
|
-
private reconnectPromise: Promise<void> | null = null;
|
|
317
523
|
|
|
318
524
|
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
319
525
|
private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
@@ -327,10 +533,21 @@ export class McplServerConnection extends EventEmitter {
|
|
|
327
533
|
// reconciliation keeps agent wakes behind a durable barrier.
|
|
328
534
|
private controlPlaneReady = false;
|
|
329
535
|
private dataPlaneReady = false;
|
|
330
|
-
private bufferedEvents:
|
|
536
|
+
private bufferedEvents: HeldItem[] = [];
|
|
331
537
|
/** How many of bufferedEvents are server→host requests, each holding a
|
|
332
538
|
* responder its server waits on. Every change to bufferedEvents keeps it. */
|
|
333
539
|
private heldRequests = 0;
|
|
540
|
+
/** How many of bufferedEvents are requests a transport boundary kept:
|
|
541
|
+
* incoming messages and pushes whose transport has closed. No server waits
|
|
542
|
+
* on them, since their answers can't reach it (sameTransport), so they
|
|
543
|
+
* stay out of the held-request budget, which bounds what a live server
|
|
544
|
+
* waits on: a dead process's backlog must not refuse the next process's
|
|
545
|
+
* registration. They have their own bound at the same size, past which a
|
|
546
|
+
* kept request is dropped and logged, there being no one to refuse. */
|
|
547
|
+
private keptRequests = 0;
|
|
548
|
+
/** Kept requests dropped at their bound since the planes last opened
|
|
549
|
+
* (ready()), so the first of each run of drops is logged. */
|
|
550
|
+
private droppedKeptRequests = 0;
|
|
334
551
|
/** Cap on held notifications (responder-less events) during a plane pause
|
|
335
552
|
* (issue #122), counted apart from requests: past it, the oldest held
|
|
336
553
|
* notification is dropped. */
|
|
@@ -340,7 +557,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
340
557
|
* bounds a server that pipelines requests without waiting for answers. A
|
|
341
558
|
* server that doesn't send a refused request again loses it (discord-mcpl
|
|
342
559
|
* doesn't), so the budget sits far above the notifications' cap, to leave
|
|
343
|
-
* a busy server's long pause room.
|
|
560
|
+
* a busy server's long pause room. Requests a transport boundary kept are
|
|
561
|
+
* counted apart from it (keptRequests), with a bound of the same size past
|
|
562
|
+
* which they're dropped, since no server waits on them. */
|
|
344
563
|
private static readonly MAX_HELD_REQUESTS = 10_000;
|
|
345
564
|
private droppedBufferedEvents = 0;
|
|
346
565
|
/** Requests refused at the budget since the planes last opened (ready()),
|
|
@@ -357,13 +576,30 @@ export class McplServerConnection extends EventEmitter {
|
|
|
357
576
|
/** Consecutive failed connect attempts since the last successful handshake.
|
|
358
577
|
* Drives the exponential backoff and is reported on reconnect events. */
|
|
359
578
|
private reconnectAttempts = 0;
|
|
579
|
+
/** The reconnect attempt in flight, so close() can abort it and wait for
|
|
580
|
+
* its cleanup. */
|
|
581
|
+
private reconnecting: { abort: AbortController; done: Promise<void> } | null = null;
|
|
582
|
+
/**
|
|
583
|
+
* Launches whose cleanup couldn't confirm that the child exited. While any
|
|
584
|
+
* is held, reconnecting is halted, since another launch could run beside
|
|
585
|
+
* one that's still alive. close() retries their reap.
|
|
586
|
+
*/
|
|
587
|
+
private unreaped: Array<{ transport: McplTransport; failure: Error }> = [];
|
|
360
588
|
|
|
361
589
|
/** Whether a background reconnect loop will revive this connection after a
|
|
362
|
-
* drop. False once close() has been called (explicit close stops retrying)
|
|
590
|
+
* drop. False once close() has been called (explicit close stops retrying),
|
|
591
|
+
* after a protocol-version verdict, and while a launch is unreaped. */
|
|
363
592
|
get willReconnect(): boolean {
|
|
364
593
|
return this.reconnectEnabled;
|
|
365
594
|
}
|
|
366
595
|
|
|
596
|
+
/** Whether a child this connection launched couldn't be confirmed exited.
|
|
597
|
+
* While it's true the connection owns that child, so whatever registered
|
|
598
|
+
* it keeps its id held; close() retries the reap. */
|
|
599
|
+
get holdsUnreapedChild(): boolean {
|
|
600
|
+
return this.unreaped.length > 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
367
603
|
/** Closed with no reconnect to come: after close(), whether or not its
|
|
368
604
|
* transport close succeeded, or a drop with reconnect off. A connection
|
|
369
605
|
* between retries keeps reconnectEnabled, so it isn't closed for good. */
|
|
@@ -399,67 +635,205 @@ export class McplServerConnection extends EventEmitter {
|
|
|
399
635
|
* construction (when setupMessageRouting starts emitting) and now (when
|
|
400
636
|
* the caller has attached listeners via wireMcplEvents).
|
|
401
637
|
*
|
|
402
|
-
*
|
|
403
|
-
* when it closed, so what
|
|
404
|
-
* closed connection sends no response, so nothing it held
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* it held, such as a reconnect stub's connect-failed,
|
|
408
|
-
* listeners
|
|
638
|
+
* Once the connection is closed for good it readies nothing. Its policy was
|
|
639
|
+
* reset when it closed, so what the server sent that needs a grant would be
|
|
640
|
+
* refused, and a closed connection sends no response, so nothing it held
|
|
641
|
+
* could be answered. It delivers only its own reports of how it ended (see
|
|
642
|
+
* {@link deliverOwnReports}). A connection between retries isn't closed for
|
|
643
|
+
* good: what it held, such as a reconnect stub's connect-failed, reaches
|
|
644
|
+
* its listeners as it does any connection's, except the incoming messages
|
|
645
|
+
* and pushes a transport boundary kept, which wait for the next
|
|
646
|
+
* transport's grant (see {@link flushHeld}).
|
|
409
647
|
*/
|
|
410
648
|
ready(): void {
|
|
411
|
-
if (this.closedForGood)
|
|
649
|
+
if (this.closedForGood) {
|
|
650
|
+
this.deliverOwnReports();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
412
653
|
this.controlPlaneReady = true;
|
|
413
654
|
this.dataPlaneReady = true;
|
|
414
655
|
const pending = this.bufferedEvents;
|
|
415
|
-
this.
|
|
656
|
+
this.replaceHeld([]);
|
|
416
657
|
// The cap's drops are counted per pause: this flush ends one, so its line
|
|
417
658
|
// reports that pause's drops, and the next pause counts (and logs) its own.
|
|
418
659
|
const dropped = this.droppedBufferedEvents;
|
|
419
660
|
this.droppedBufferedEvents = 0;
|
|
420
|
-
this.heldRequests = 0;
|
|
421
661
|
this.refusedHeldRequests = 0;
|
|
662
|
+
this.droppedKeptRequests = 0;
|
|
422
663
|
if (pending.length > 200) {
|
|
423
664
|
console.error(
|
|
424
665
|
`[mcpl] ${this.id}: flushing ${pending.length} buffered event(s) synchronously ` +
|
|
425
666
|
`(long pause backlog${dropped > 0 ? `; ${dropped} dropped at cap` : ''})`,
|
|
426
667
|
);
|
|
427
668
|
}
|
|
428
|
-
for
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
669
|
+
// Re-enter the gate for every buffered item. A control handler (notably
|
|
670
|
+
// tools/list_changed) may install a newer data-plane barrier while this
|
|
671
|
+
// snapshot is being flushed; later data items must observe that pause.
|
|
672
|
+
this.flushHeld(pending);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Re-emit a held snapshot through the admission gate (this.emit), inside
|
|
677
|
+
* the containment boundary every inbound event shares
|
|
678
|
+
* (dispatchInboundEvent), in admission order (admissionOrder). A transport
|
|
679
|
+
* boundary crossed during the flush (a listener that closes the connection,
|
|
680
|
+
* say) leaves the rest of the snapshot to the boundary rule, as if it had
|
|
681
|
+
* still been held: what the server said about its own process is dropped,
|
|
682
|
+
* and the rest is held again, after whatever this flush held.
|
|
683
|
+
*
|
|
684
|
+
* Between transports, the incoming messages and pushes a boundary kept
|
|
685
|
+
* stay held for the next transport's grant: admitted now, they would meet
|
|
686
|
+
* the empty grant the boundary left, with no transport to answer on. The
|
|
687
|
+
* host's own reports about the transport (a stub's connect-failed, say)
|
|
688
|
+
* need no grant and go on.
|
|
689
|
+
*/
|
|
690
|
+
private flushHeld(snapshot: HeldItem[]): void {
|
|
691
|
+
const waits = (item: HeldItem) => item.kept === true && McplServerConnection.WORLD_EVENTS.has(item.event);
|
|
692
|
+
let pending = snapshot;
|
|
693
|
+
if (!this.isConnected && snapshot.some(waits)) {
|
|
694
|
+
this.replaceHeld([...this.bufferedEvents, ...snapshot.filter(waits)]);
|
|
695
|
+
pending = snapshot.filter((item) => !waits(item));
|
|
696
|
+
}
|
|
697
|
+
const ordered = McplServerConnection.admissionOrder(pending);
|
|
698
|
+
const epoch = this.transportEpoch;
|
|
699
|
+
for (let index = 0; index < ordered.length; index++) {
|
|
700
|
+
if (this.transportEpoch !== epoch) {
|
|
701
|
+
this.replaceHeld([...this.bufferedEvents, ...this.keepAcrossBoundary(ordered.slice(index))]);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const { event, args, kept } = ordered[index]!;
|
|
705
|
+
// What it can't admit yet is held again, as the admission gate would
|
|
706
|
+
// hold it, with what a boundary kept still marked as kept.
|
|
707
|
+
if (kept && !this.planeReadyFor(event)) this.hold(event, args, kept);
|
|
708
|
+
else this.dispatchInboundEvent(event, ...args);
|
|
433
709
|
}
|
|
434
710
|
}
|
|
435
711
|
|
|
712
|
+
/** Whether the plane an event belongs to is open (lifecycle events always
|
|
713
|
+
* pass), as the admission gate (emit) decides it. */
|
|
714
|
+
private planeReadyFor(event: string): boolean {
|
|
715
|
+
return McplServerConnection.isLifecycleEvent(event)
|
|
716
|
+
|| (McplServerConnection.isControlPlaneEvent(event) ? this.controlPlaneReady : this.dataPlaneReady);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* The order a flush admits a held snapshot in: as it was held, except that
|
|
721
|
+
* when it holds items a transport boundary kept, the current transport's
|
|
722
|
+
* channel registrations (`channels-register`, `channels-changed`) go
|
|
723
|
+
* first, each unless it names a channel that something the transport sent
|
|
724
|
+
* before it, and that stays where it was held, also names (below). The
|
|
725
|
+
* registry refuses a message for a channel it doesn't know
|
|
726
|
+
* (§14.5). It keeps a server's channels across a reconnect, but a
|
|
727
|
+
* registration the old process sent that was still held at the boundary
|
|
728
|
+
* was dropped with it (keepAcrossBoundary), so the next process registers
|
|
729
|
+
* that channel anew, behind what was kept: a kept message for it admitted
|
|
730
|
+
* first would be refused, and lost, since a bridge that recorded it as
|
|
731
|
+
* forwarded doesn't send it again.
|
|
732
|
+
* Nothing else moves, so a snapshot with nothing kept is flushed as it was
|
|
733
|
+
* held. A registration also passes whatever its own transport sent before
|
|
734
|
+
* it, which a server that announces its channels first, as a bridge does,
|
|
735
|
+
* never has. A removal stays where it was held, with whatever else it adds
|
|
736
|
+
* or updates, as everything does that isn't a registration. A registration
|
|
737
|
+
* that names a channel which an earlier removal, or an earlier registration
|
|
738
|
+
* that stays, names stays where it was held too, so the writes to one
|
|
739
|
+
* channel keep their order: a channel's removal and its re-registration,
|
|
740
|
+
* and two registrations or updates of it. In a flush that opens both
|
|
741
|
+
* planes, what the transport sent before a removal is admitted before it,
|
|
742
|
+
* its messages for that channel included. A flush that opens only the control
|
|
743
|
+
* plane, as during a quiesce, admits the control traffic, a removal
|
|
744
|
+
* included, and holds the data plane's again until that plane opens, as
|
|
745
|
+
* live traffic is during a quiesce: a message held for a channel a removal
|
|
746
|
+
* took is then refused as the data plane opens.
|
|
747
|
+
*/
|
|
748
|
+
private static admissionOrder(pending: HeldItem[]): HeldItem[] {
|
|
749
|
+
if (!pending.some((item) => item.kept)) return pending;
|
|
750
|
+
const registrations = new Set<HeldItem>();
|
|
751
|
+
// The channels named so far by a removal or registration that stays
|
|
752
|
+
// where it was held.
|
|
753
|
+
const staying = new Set<string>();
|
|
754
|
+
for (const item of pending) {
|
|
755
|
+
if (item.kept) continue;
|
|
756
|
+
if (item.event !== 'channels-register' && item.event !== 'channels-changed') continue;
|
|
757
|
+
const removes = McplServerConnection.removedChannelIds(item);
|
|
758
|
+
const registers = McplServerConnection.registeredChannelIds(item);
|
|
759
|
+
if (removes.length === 0 && !registers.some((id) => staying.has(id))) {
|
|
760
|
+
registrations.add(item);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
for (const id of [...removes, ...registers]) staying.add(id);
|
|
764
|
+
}
|
|
765
|
+
if (registrations.size === 0) return pending;
|
|
766
|
+
return [...registrations, ...pending.filter((item) => !registrations.has(item))];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** The channels a held `channels-changed` removes. The registry takes any
|
|
770
|
+
* entry of `removed` as a channel id, by its string form, so a number
|
|
771
|
+
* removes the channel of its digits; an object is left out here, since
|
|
772
|
+
* its string form can throw. */
|
|
773
|
+
private static removedChannelIds(item: HeldItem): string[] {
|
|
774
|
+
if (item.event !== 'channels-changed') return [];
|
|
775
|
+
const params = item.args[0] as { removed?: unknown } | null | undefined;
|
|
776
|
+
return Array.isArray(params?.removed)
|
|
777
|
+
? params.removed.filter((id) => typeof id !== 'object' || id === null).map((id) => String(id))
|
|
778
|
+
: [];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** The channels a held `channels-register` registers, or a held
|
|
782
|
+
* `channels-changed` adds or updates. */
|
|
783
|
+
private static registeredChannelIds(item: HeldItem): string[] {
|
|
784
|
+
const params = item.args[0] as { channels?: unknown; added?: unknown; updated?: unknown } | null | undefined;
|
|
785
|
+
const lists = item.event === 'channels-register' ? [params?.channels] : [params?.added, params?.updated];
|
|
786
|
+
return lists.flatMap((list) => (Array.isArray(list) ? list : []))
|
|
787
|
+
.map((channel) => (channel as { id?: unknown } | null)?.id)
|
|
788
|
+
.filter((id): id is string => typeof id === 'string');
|
|
789
|
+
}
|
|
790
|
+
|
|
436
791
|
/**
|
|
437
792
|
* Release only server-establishment and lifecycle/control traffic. Responses
|
|
438
793
|
* to host requests are never event-buffered, so tools/list and tools/call
|
|
439
794
|
* responses remain live as well. Inference-bearing requests stay queued in
|
|
440
795
|
* their original relative order until {@link ready} is called. Like
|
|
441
|
-
* {@link ready},
|
|
796
|
+
* {@link ready}, once the connection is closed for good it delivers only
|
|
797
|
+
* its own reports of how it ended.
|
|
442
798
|
*/
|
|
443
799
|
readyControlPlane(): void {
|
|
444
|
-
if (this.closedForGood)
|
|
800
|
+
if (this.closedForGood) {
|
|
801
|
+
this.deliverOwnReports();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
445
804
|
this.controlPlaneReady = true;
|
|
446
805
|
const pending = this.bufferedEvents;
|
|
447
|
-
this.
|
|
448
|
-
this.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
806
|
+
this.replaceHeld([]);
|
|
807
|
+
// Re-enter the ADMISSION gate (this.emit), never super.emit: control
|
|
808
|
+
// events include channels-register/changed, which are grant-gated —
|
|
809
|
+
// the old direct flush bypassed positive-grant enforcement entirely
|
|
810
|
+
// (PR #79 review blocker 3). Data-plane items re-buffer in order via
|
|
811
|
+
// the same call, since dataPlaneReady is still false.
|
|
812
|
+
this.flushHeld(pending);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* A connection closed for good still says how it ended. Its own buffered
|
|
817
|
+
* connect-failed and reconnect-failed, such as the permanent failure of a
|
|
818
|
+
* launch held as unreaped before the first gate released it, are the
|
|
819
|
+
* framework's records of it: they need no grant, and its listeners act on
|
|
820
|
+
* them (a trace, and for a permanent failure an alert and its tools leaving
|
|
821
|
+
* the list). What the server sent stays held.
|
|
822
|
+
*/
|
|
823
|
+
private deliverOwnReports(): void {
|
|
824
|
+
const own = (item: { event: string }) => item.event === 'connect-failed' || item.event === 'reconnect-failed';
|
|
825
|
+
const reports = this.bufferedEvents.filter(own);
|
|
826
|
+
if (reports.length === 0) return;
|
|
827
|
+
// The host's own reports have no responder, so the request counts stand.
|
|
828
|
+
this.bufferedEvents = this.bufferedEvents.filter((item) => !own(item));
|
|
829
|
+
for (const { event, args } of reports) super.emit(event, ...args);
|
|
457
830
|
}
|
|
458
831
|
|
|
459
832
|
/**
|
|
460
833
|
* Close the data plane synchronously while leaving an already-released
|
|
461
|
-
* control plane live. Used before
|
|
462
|
-
*
|
|
834
|
+
* control plane live. Used before list-change reconciliation so no new
|
|
835
|
+
* inference event can beat barrier installation. A reconnect closes both
|
|
836
|
+
* planes instead.
|
|
463
837
|
*/
|
|
464
838
|
pauseDataPlane(): void {
|
|
465
839
|
this.dataPlaneReady = false;
|
|
@@ -472,12 +846,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
472
846
|
*/
|
|
473
847
|
override emit(event: string | symbol, ...args: unknown[]): boolean {
|
|
474
848
|
const name = typeof event === 'string' ? event : '';
|
|
475
|
-
if (
|
|
476
|
-
McplServerConnection.isLifecycleEvent(name)
|
|
477
|
-
|| (McplServerConnection.isControlPlaneEvent(name)
|
|
478
|
-
? this.controlPlaneReady
|
|
479
|
-
: this.dataPlaneReady)
|
|
480
|
-
) {
|
|
849
|
+
if (this.planeReadyFor(name)) {
|
|
481
850
|
// Positive-grant admission (§5.4/§14.1), at the single choke-point
|
|
482
851
|
// every path shares: live routing AND buffer flushes re-enter here, so
|
|
483
852
|
// an event buffered while the connection was staged is authorized
|
|
@@ -536,6 +905,14 @@ export class McplServerConnection extends EventEmitter {
|
|
|
536
905
|
}
|
|
537
906
|
return super.emit(event, ...args);
|
|
538
907
|
}
|
|
908
|
+
return this.hold(name, args);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Hold an event until its plane opens, `kept` when a transport boundary
|
|
913
|
+
* keeps it for the next transport's grant (afterBoundary).
|
|
914
|
+
*/
|
|
915
|
+
private hold(name: string, args: unknown[], kept?: true): true {
|
|
539
916
|
// Held until the plane opens. A long pause (a quiesce, say) on a busy or a
|
|
540
917
|
// runaway server would otherwise grow this array without bound, so
|
|
541
918
|
// requests and notifications each have a bound, counted apart. A request
|
|
@@ -544,11 +921,30 @@ export class McplServerConnection extends EventEmitter {
|
|
|
544
921
|
// the code of the host's other transient refusals, and its server can send
|
|
545
922
|
// it again. A notification has no one to tell, so past
|
|
546
923
|
// MAX_HELD_NOTIFICATIONS (issue #122) the oldest held notification is
|
|
547
|
-
// dropped to make room. A
|
|
548
|
-
//
|
|
549
|
-
//
|
|
924
|
+
// dropped to make room. A request a closed transport left (`kept`) has
|
|
925
|
+
// no server waiting on it, so it's counted apart, as keptRequests, and
|
|
926
|
+
// dropped past its own bound. A flush takes the whole buffer and zeroes
|
|
927
|
+
// its counts, so what it holds again was admitted within these bounds
|
|
928
|
+
// and stays within them.
|
|
550
929
|
const responder = args[1] as { respond?: unknown; respondError?: (code: number, message: string) => void } | undefined;
|
|
551
930
|
const isRequest = McplServerConnection.hasResponder({ args });
|
|
931
|
+
if (isRequest && kept) {
|
|
932
|
+
// A request a closed transport left (keptRequests): no server waits on
|
|
933
|
+
// it, so past its own bound it's dropped, with nobody to refuse.
|
|
934
|
+
if (this.keptRequests >= McplServerConnection.MAX_HELD_REQUESTS) {
|
|
935
|
+
this.droppedKeptRequests++;
|
|
936
|
+
if (this.droppedKeptRequests % 100 === 1) {
|
|
937
|
+
console.error(
|
|
938
|
+
`[mcpl] ${this.id}: kept-request bound (${McplServerConnection.MAX_HELD_REQUESTS}) — ` +
|
|
939
|
+
`dropped ${this.droppedKeptRequests} request(s) a closed transport left`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
this.bufferedEvents.push({ event: name, args, kept });
|
|
945
|
+
this.keptRequests++;
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
552
948
|
if (isRequest && this.heldRequests >= McplServerConnection.MAX_HELD_REQUESTS) {
|
|
553
949
|
this.refusedHeldRequests++;
|
|
554
950
|
if (this.refusedHeldRequests % 100 === 1) {
|
|
@@ -563,7 +959,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
563
959
|
);
|
|
564
960
|
return true;
|
|
565
961
|
}
|
|
566
|
-
if (!isRequest && this.bufferedEvents.length - this.heldRequests >= McplServerConnection.MAX_HELD_NOTIFICATIONS) {
|
|
962
|
+
if (!isRequest && this.bufferedEvents.length - this.heldRequests - this.keptRequests >= McplServerConnection.MAX_HELD_NOTIFICATIONS) {
|
|
567
963
|
this.droppedBufferedEvents++;
|
|
568
964
|
if (this.droppedBufferedEvents % 100 === 1) {
|
|
569
965
|
console.error(
|
|
@@ -574,7 +970,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
574
970
|
const oldest = this.bufferedEvents.findIndex((item) => !McplServerConnection.hasResponder(item));
|
|
575
971
|
if (oldest >= 0) this.bufferedEvents.splice(oldest, 1);
|
|
576
972
|
}
|
|
577
|
-
this.bufferedEvents.push({ event: name, args });
|
|
973
|
+
this.bufferedEvents.push(kept ? { event: name, args, kept } : { event: name, args });
|
|
578
974
|
if (isRequest) this.heldRequests++;
|
|
579
975
|
return true;
|
|
580
976
|
}
|
|
@@ -585,6 +981,18 @@ export class McplServerConnection extends EventEmitter {
|
|
|
585
981
|
return typeof responder?.respond === 'function';
|
|
586
982
|
}
|
|
587
983
|
|
|
984
|
+
/** Events routed from a server's transport that report the world, which
|
|
985
|
+
* the next process doesn't send again when its bridge records them as
|
|
986
|
+
* forwarded first: kept at a transport boundary while held. */
|
|
987
|
+
private static readonly WORLD_EVENTS: ReadonlySet<string> = new Set(['channels-incoming', 'push-event']);
|
|
988
|
+
|
|
989
|
+
/** Events routed from a server's transport that speak for its process, not
|
|
990
|
+
* the world: dropped at a transport boundary while held. */
|
|
991
|
+
private static get PROCESS_EVENTS(): ReadonlySet<string> {
|
|
992
|
+
return new Set(Object.values(McplServerConnection.METHOD_TO_EVENT)
|
|
993
|
+
.filter((event) => !McplServerConnection.WORLD_EVENTS.has(event)));
|
|
994
|
+
}
|
|
995
|
+
|
|
588
996
|
/** Event-name form of METHOD_TO_REQUIRED_CAPABILITY, for admission-time
|
|
589
997
|
* enforcement in emit(). */
|
|
590
998
|
private static readonly EVENT_TO_REQUIRED_CAPABILITY: Record<string, string> = {
|
|
@@ -636,25 +1044,55 @@ export class McplServerConnection extends EventEmitter {
|
|
|
636
1044
|
* capability negotiation, and return a ready-to-use connection.
|
|
637
1045
|
*
|
|
638
1046
|
* Works over stdio (spawned child) or WebSocket, selected by `config` (see
|
|
639
|
-
* {@link openTransport}). Throws if the
|
|
640
|
-
*
|
|
1047
|
+
* {@link openTransport}). Throws if the configuration can't be used (before
|
|
1048
|
+
* anything is spawned or dialed), the transport can't be opened, the server
|
|
1049
|
+
* closes/errors before the handshake, or the handshake times out. If the
|
|
1050
|
+
* failed launch's cleanup can't confirm its child exited, it throws
|
|
1051
|
+
* {@link McplUnreapedLaunchError}, whose connection owns that launch.
|
|
641
1052
|
*/
|
|
642
1053
|
static async connect(
|
|
643
1054
|
config: McplServerConfig,
|
|
644
1055
|
hostCapabilities: McplHostCapabilities,
|
|
1056
|
+
signal?: AbortSignal,
|
|
645
1057
|
): Promise<McplServerConnection> {
|
|
646
|
-
|
|
1058
|
+
McplServerConnection.checkConfig(config);
|
|
1059
|
+
return McplServerConnection.connectChecked(config, hostCapabilities, signal);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** {@link connect}, for a configuration its caller has already checked
|
|
1063
|
+
* (and warned about) once. `signal` ends the connect at once, its launch
|
|
1064
|
+
* reaped as for any failed handshake: its caller's stop() or a
|
|
1065
|
+
* disconnect of its id. */
|
|
1066
|
+
private static async connectChecked(
|
|
1067
|
+
config: McplServerConfig,
|
|
1068
|
+
hostCapabilities: McplHostCapabilities,
|
|
1069
|
+
signal?: AbortSignal,
|
|
1070
|
+
): Promise<McplServerConnection> {
|
|
1071
|
+
let established: Awaited<ReturnType<typeof McplServerConnection.handshake>>;
|
|
1072
|
+
try {
|
|
1073
|
+
established = await McplServerConnection.handshake(config, hostCapabilities, signal);
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
if (!(error instanceof HandshakeCleanupError)) throw error;
|
|
1076
|
+
// The launch may still be running. A connection owns it, closed and
|
|
1077
|
+
// halted, so the caller can retry the reap by closing it.
|
|
1078
|
+
const owner = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
1079
|
+
owner.holdUnreaped(error.transport, error.cleanup);
|
|
1080
|
+
throw new McplUnreapedLaunchError(owner, error.failure, error.cleanup);
|
|
1081
|
+
}
|
|
1082
|
+
const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = established;
|
|
647
1083
|
|
|
648
1084
|
const connection = new McplServerConnection(config.id, capabilities, transport);
|
|
649
1085
|
connection.droppedCapabilities = droppedCapabilities;
|
|
650
1086
|
connection.mcpToolsAdvertised = mcpToolsAdvertised;
|
|
1087
|
+
connection.protocolVersion = protocolVersion;
|
|
651
1088
|
connection.allowHostCommands = config.allowHostCommands === true;
|
|
652
1089
|
connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
653
1090
|
|
|
654
|
-
//
|
|
1091
|
+
// Its configuration, which a reconnect reads and every log line of the
|
|
1092
|
+
// server's text redacts with. Only `true` turns reconnect on, as at
|
|
655
1093
|
// every other reader of it.
|
|
1094
|
+
connection.config = config;
|
|
656
1095
|
if (config.reconnect === true) {
|
|
657
|
-
connection.config = config;
|
|
658
1096
|
connection.hostCapabilities = hostCapabilities;
|
|
659
1097
|
connection.reconnectEnabled = true;
|
|
660
1098
|
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
@@ -664,18 +1102,49 @@ export class McplServerConnection extends EventEmitter {
|
|
|
664
1102
|
return connection;
|
|
665
1103
|
}
|
|
666
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* This engine's configuration check, made before anything is spawned or
|
|
1107
|
+
* dialed: every problem {@link checkServerConfig} finds, the same check the
|
|
1108
|
+
* framework applies, and the modern family, which belongs to
|
|
1109
|
+
* ModernMcpConnection (an http(s) url is never dialed as a WebSocket).
|
|
1110
|
+
* Every configuration that worked before still passes but the one
|
|
1111
|
+
* serverConfigProblems names.
|
|
1112
|
+
*/
|
|
1113
|
+
private static checkConfig(config: McplServerConfig): void {
|
|
1114
|
+
const { family } = checkServerConfig(config);
|
|
1115
|
+
if (family === 'modern') {
|
|
1116
|
+
throw new Error(
|
|
1117
|
+
`MCP server "${config.id}" is configured for modern MCP (${MODERN_MCP_PROTOCOL_VERSION}); ` +
|
|
1118
|
+
`McplServerConnection speaks the legacy family (${MCP_PROTOCOL_VERSION} + MCPL) only`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
667
1123
|
/**
|
|
668
1124
|
* Open a fresh transport and run the MCP/MCPL initialize handshake over it,
|
|
669
1125
|
* returning the connected transport plus the negotiated capabilities. Shared
|
|
670
1126
|
* by {@link connect} and {@link attemptReconnect} so both transports handshake
|
|
671
1127
|
* identically. On any failure the transport is closed so a spawned child /
|
|
672
|
-
* open socket never leaks.
|
|
1128
|
+
* open socket never leaks. If that close can't confirm the child exited,
|
|
1129
|
+
* the failure is a {@link HandshakeCleanupError} carrying the transport, so
|
|
1130
|
+
* the caller keeps owning it. `signal` ends a handshake whose connection is
|
|
1131
|
+
* closed meanwhile: it cuts a WebSocket dial short, is checked once the
|
|
1132
|
+
* transport is open, and races the initialize answer.
|
|
673
1133
|
*/
|
|
674
1134
|
private static async handshake(
|
|
675
1135
|
config: McplServerConfig,
|
|
676
1136
|
hostCapabilities: McplHostCapabilities,
|
|
677
|
-
|
|
678
|
-
|
|
1137
|
+
signal?: AbortSignal,
|
|
1138
|
+
): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet<string>; mcpToolsAdvertised: boolean; protocolVersion: string }> {
|
|
1139
|
+
const closedWhileConnecting = () => new Error(`MCPL server "${config.id}" was closed while connecting`);
|
|
1140
|
+
if (signal?.aborted) throw closedWhileConnecting();
|
|
1141
|
+
let transport: McplTransport;
|
|
1142
|
+
try {
|
|
1143
|
+
transport = await openTransport(config, signal);
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (signal?.aborted) throw closedWhileConnecting();
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
679
1148
|
// Once the handshake stops listening for 'error', this logs what the
|
|
680
1149
|
// transport reports, until the handshake hands the transport on, or for
|
|
681
1150
|
// good if the handshake fails. EventEmitter throws an 'error' that has no
|
|
@@ -683,10 +1152,11 @@ export class McplServerConnection extends EventEmitter {
|
|
|
683
1152
|
// WebSocket frame that arrives with the initialize response, or while a
|
|
684
1153
|
// failed handshake's close() runs, is such an error.
|
|
685
1154
|
const logAfterSettled = (err: Error) => {
|
|
686
|
-
console.error(`[mcpl] ${config.id}: transport error after the handshake settled: ${err.message}`);
|
|
1155
|
+
console.error(`[mcpl] ${config.id}: transport error after the handshake settled: ${redactServerText(err.message, config)}`);
|
|
687
1156
|
};
|
|
688
1157
|
|
|
689
1158
|
try {
|
|
1159
|
+
if (signal?.aborted) throw closedWhileConnecting();
|
|
690
1160
|
const initId = 0; // use id=0 for the handshake
|
|
691
1161
|
const initRequest: JsonRpcRequest = {
|
|
692
1162
|
jsonrpc: '2.0',
|
|
@@ -699,9 +1169,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
699
1169
|
},
|
|
700
1170
|
};
|
|
701
1171
|
|
|
702
|
-
// Await the initialize response, racing an early transport close/error
|
|
703
|
-
// a timeout.
|
|
704
|
-
const { mcpl: rawCapabilities, mcpToolsAdvertised } = await new Promise<{ mcpl: McplCapabilities | null; mcpToolsAdvertised: boolean }>((resolve, reject) => {
|
|
1172
|
+
// Await the initialize response, racing an early transport close/error,
|
|
1173
|
+
// a timeout and the owner's close. Every path cleans up its listeners.
|
|
1174
|
+
const { mcpl: rawCapabilities, mcpToolsAdvertised, protocolVersion } = await new Promise<{ mcpl: McplCapabilities | null; mcpToolsAdvertised: boolean; protocolVersion: string }>((resolve, reject) => {
|
|
705
1175
|
// When the transport fails, the end of the server's stderr often says
|
|
706
1176
|
// why, such as a missing token or a crash at startup. It goes on the
|
|
707
1177
|
// error as `stderrTail`, never into the message: an error's message
|
|
@@ -715,9 +1185,14 @@ export class McplServerConnection extends EventEmitter {
|
|
|
715
1185
|
transport.off('line', onLine);
|
|
716
1186
|
transport.off('close', onClose);
|
|
717
1187
|
transport.off('error', onError);
|
|
1188
|
+
signal?.removeEventListener('abort', onAbort);
|
|
718
1189
|
transport.on('error', logAfterSettled);
|
|
719
1190
|
clearTimeout(timer);
|
|
720
1191
|
};
|
|
1192
|
+
const onAbort = () => {
|
|
1193
|
+
cleanup();
|
|
1194
|
+
reject(closedWhileConnecting());
|
|
1195
|
+
};
|
|
721
1196
|
const onLine = (line: string) => {
|
|
722
1197
|
let msg: JsonRpcResponse;
|
|
723
1198
|
try {
|
|
@@ -748,10 +1223,26 @@ export class McplServerConnection extends EventEmitter {
|
|
|
748
1223
|
// exception, which ends a host that doesn't catch them, and a
|
|
749
1224
|
// handshake left to wait forever. It's the server's text in an
|
|
750
1225
|
// error's message, so it's bounded as errors.ts says.
|
|
751
|
-
reject(
|
|
1226
|
+
reject(msg.error.code === UNSUPPORTED_PROTOCOL_VERSION_CODE
|
|
1227
|
+
? protocolVersionRejection(config.id, msg.error)
|
|
1228
|
+
: new Error(`MCPL server "${config.id}" initialize error: ${boundedForLog(describeCause(msg.error.message))}`));
|
|
752
1229
|
return;
|
|
753
1230
|
}
|
|
754
1231
|
const result = msg.result as Record<string, unknown> | undefined;
|
|
1232
|
+
// The server's answer is the established revision. This engine
|
|
1233
|
+
// implements exactly one, so any other answer (or none) means the
|
|
1234
|
+
// two sides never agreed on what they're speaking.
|
|
1235
|
+
const returned = typeof result?.protocolVersion === 'string' ? result.protocolVersion : null;
|
|
1236
|
+
if (returned !== MCP_PROTOCOL_VERSION) {
|
|
1237
|
+
reject(new McplProtocolVersionError(
|
|
1238
|
+
`MCPL server "${config.id}" answered initialize with protocolVersion ` +
|
|
1239
|
+
`${returned === null ? '(none)' : JSON.stringify(returned)}; this client's legacy engine speaks only ${MCP_PROTOCOL_VERSION}`,
|
|
1240
|
+
'mismatch',
|
|
1241
|
+
returned,
|
|
1242
|
+
null,
|
|
1243
|
+
));
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
755
1246
|
const caps = result?.capabilities as Record<string, unknown> | undefined;
|
|
756
1247
|
const experimental = caps?.experimental as Record<string, unknown> | undefined;
|
|
757
1248
|
// §5.1: `tools` capability is the OUTER standard MCP member — the
|
|
@@ -760,6 +1251,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
760
1251
|
resolve({
|
|
761
1252
|
mcpl: (experimental?.mcpl as McplCapabilities) ?? null,
|
|
762
1253
|
mcpToolsAdvertised: caps?.tools !== undefined,
|
|
1254
|
+
protocolVersion: returned,
|
|
763
1255
|
});
|
|
764
1256
|
};
|
|
765
1257
|
const onClose = (info: TransportCloseInfo) => {
|
|
@@ -782,6 +1274,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
782
1274
|
reject(failed(`MCPL server "${config.id}" initialize handshake timed out`));
|
|
783
1275
|
}, INITIALIZE_TIMEOUT_MS);
|
|
784
1276
|
|
|
1277
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
785
1278
|
transport.on('line', onLine);
|
|
786
1279
|
transport.on('close', onClose);
|
|
787
1280
|
transport.on('error', onError);
|
|
@@ -808,10 +1301,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
808
1301
|
// listener of its own, in the continuation of this return, before any
|
|
809
1302
|
// I/O callback can emit.
|
|
810
1303
|
transport.off('error', logAfterSettled);
|
|
811
|
-
return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised };
|
|
1304
|
+
return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised, protocolVersion };
|
|
812
1305
|
} catch (err) {
|
|
813
|
-
// Never leak the child / socket if the handshake fails.
|
|
814
|
-
|
|
1306
|
+
// Never leak the child / socket if the handshake fails. A cleanup that
|
|
1307
|
+
// can't confirm the child exited is part of the failure, never
|
|
1308
|
+
// swallowed: the launch may still be running, so the caller keeps it.
|
|
1309
|
+
try {
|
|
1310
|
+
await transport.close();
|
|
1311
|
+
} catch (cleanup) {
|
|
1312
|
+
throw new HandshakeCleanupError(errorOf(err), errorOf(cleanup), transport);
|
|
1313
|
+
}
|
|
815
1314
|
throw err;
|
|
816
1315
|
}
|
|
817
1316
|
}
|
|
@@ -820,34 +1319,48 @@ export class McplServerConnection extends EventEmitter {
|
|
|
820
1319
|
* Connect with reconnect support.
|
|
821
1320
|
* When `config.reconnect` is true and the initial connection fails,
|
|
822
1321
|
* resolves immediately with null capabilities and retries in the background.
|
|
1322
|
+
* A configuration this engine can't use, or a protocol-version verdict,
|
|
1323
|
+
* still throws: retrying can't change either. So does a failed launch that
|
|
1324
|
+
* can't be reaped, as in {@link connect}: no retry starts beside a child
|
|
1325
|
+
* that may still be running, and the {@link McplUnreapedLaunchError}
|
|
1326
|
+
* carries the connection that owns it, whose close() retries the reap.
|
|
823
1327
|
* Adapted from Anarchid/agent-framework@mcpl-module-proto.
|
|
824
1328
|
*/
|
|
825
1329
|
static async connectWithReconnect(
|
|
826
1330
|
config: McplServerConfig,
|
|
827
1331
|
hostCapabilities: McplHostCapabilities,
|
|
1332
|
+
signal?: AbortSignal,
|
|
828
1333
|
): Promise<McplServerConnection> {
|
|
1334
|
+
// Outside the retry path below: a configuration error is never retried.
|
|
1335
|
+
McplServerConnection.checkConfig(config);
|
|
829
1336
|
try {
|
|
830
|
-
return await McplServerConnection.
|
|
1337
|
+
return await McplServerConnection.connectChecked(config, hostCapabilities, signal);
|
|
831
1338
|
} catch (error) {
|
|
832
|
-
|
|
1339
|
+
// No common protocol revision is a configuration fact, not an outage:
|
|
1340
|
+
// a retry stub would restart the server forever for nothing. A connect
|
|
1341
|
+
// its caller ended (stop(), or a disconnect of its id) isn't retried
|
|
1342
|
+
// either, nor is a launch that couldn't be reaped.
|
|
1343
|
+
if (config.reconnect !== true || error instanceof McplProtocolVersionError || error instanceof McplUnreapedLaunchError || signal?.aborted) {
|
|
833
1344
|
throw error;
|
|
834
1345
|
}
|
|
835
1346
|
|
|
836
|
-
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
837
|
-
console.error(`MCPL server "${config.id}" initial connect failed, will retry: ${(error as Error).message}${withStderr(error)}`);
|
|
838
|
-
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
839
1347
|
// Surface the initial failure. The event is buffered by the emit()
|
|
840
1348
|
// override until the host wires listeners and calls ready(), so it is
|
|
841
1349
|
// not lost in the window before wireMcplEvents runs.
|
|
1350
|
+
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
1351
|
+
console.error(`MCPL server "${config.id}" initial connect failed, will retry: ${redactServerText((error as Error).message, config)}${withStderr(error, (text) => redactServerText(text, config))}`);
|
|
1352
|
+
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
842
1353
|
const stderrTail = stderrTailOf(error);
|
|
843
1354
|
stub.emit('connect-failed', { error: (error as Error).message, ...(stderrTail ? { stderrTail } : {}), attempt: 0 });
|
|
1355
|
+
stub.scheduleReconnect();
|
|
844
1356
|
return stub;
|
|
845
1357
|
}
|
|
846
1358
|
}
|
|
847
1359
|
|
|
848
1360
|
/**
|
|
849
|
-
* Create a disconnected stub connection
|
|
850
|
-
*
|
|
1361
|
+
* Create a disconnected stub connection, for a failed initial connect: one
|
|
1362
|
+
* that will reconnect in the background (its caller schedules that), or one
|
|
1363
|
+
* that owns a launch that couldn't be reaped.
|
|
851
1364
|
* @internal
|
|
852
1365
|
*/
|
|
853
1366
|
private static createDisconnectedStub(
|
|
@@ -860,15 +1373,31 @@ export class McplServerConnection extends EventEmitter {
|
|
|
860
1373
|
stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
861
1374
|
stub.config = config;
|
|
862
1375
|
stub.hostCapabilities = hostCapabilities;
|
|
863
|
-
stub.reconnectEnabled = true;
|
|
1376
|
+
stub.reconnectEnabled = config.reconnect === true;
|
|
864
1377
|
stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
865
1378
|
stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
866
1379
|
stub.reconnectAttempts = 1; // the initial connect already failed
|
|
1380
|
+
return stub;
|
|
1381
|
+
}
|
|
867
1382
|
|
|
868
|
-
|
|
869
|
-
|
|
1383
|
+
/**
|
|
1384
|
+
* Keep a launch whose cleanup couldn't confirm its child exited, and halt
|
|
1385
|
+
* reconnecting: another launch could run beside one that's still alive.
|
|
1386
|
+
* close() retries the reap.
|
|
1387
|
+
*/
|
|
1388
|
+
private holdUnreaped(transport: McplTransport, failure: Error): void {
|
|
1389
|
+
this.reconnectEnabled = false;
|
|
1390
|
+
if (this.reconnectTimer) {
|
|
1391
|
+
clearTimeout(this.reconnectTimer);
|
|
1392
|
+
this.reconnectTimer = null;
|
|
1393
|
+
}
|
|
1394
|
+
if (!this.unreaped.some((held) => held.transport === transport)) this.unreaped.push({ transport, failure });
|
|
1395
|
+
}
|
|
870
1396
|
|
|
871
|
-
|
|
1397
|
+
/** The halt, as a reconnect event's failure text. */
|
|
1398
|
+
private unreapedFailure(): string {
|
|
1399
|
+
const held = this.unreaped[0];
|
|
1400
|
+
return `the previous launch could not be reaped (${held?.failure.message ?? 'unknown'}); reconnecting is halted`;
|
|
872
1401
|
}
|
|
873
1402
|
|
|
874
1403
|
// ==========================================================================
|
|
@@ -912,8 +1441,11 @@ export class McplServerConnection extends EventEmitter {
|
|
|
912
1441
|
) as Promise<FeatureSetsUpdateResult>;
|
|
913
1442
|
}
|
|
914
1443
|
|
|
915
|
-
/** Send `inference/chunk` notification
|
|
916
|
-
|
|
1444
|
+
/** Send `inference/chunk` notification, on the transport `epoch` names: a
|
|
1445
|
+
* chunk of a request from a transport that has closed goes nowhere, as
|
|
1446
|
+
* that request's answer does. */
|
|
1447
|
+
sendInferenceChunk(params: InferenceChunkParams, epoch = this.transportEpoch): void {
|
|
1448
|
+
if (epoch !== this.transportEpoch) return;
|
|
917
1449
|
this.sendNotification(McplMethod.InferenceChunk, params as unknown as Record<string, unknown>);
|
|
918
1450
|
}
|
|
919
1451
|
|
|
@@ -1022,9 +1554,46 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1022
1554
|
// Standard MCP methods
|
|
1023
1555
|
// ==========================================================================
|
|
1024
1556
|
|
|
1025
|
-
/**
|
|
1026
|
-
|
|
1027
|
-
|
|
1557
|
+
/**
|
|
1558
|
+
* Send `tools/list` and return the server's complete tool inventory,
|
|
1559
|
+
* following `nextCursor` across pages (2024-11-05 pagination). Cursors are
|
|
1560
|
+
* opaque strings, `''` included: only an absent or null `nextCursor` ends
|
|
1561
|
+
* the inventory (a null one is how some servers say "no more", and main
|
|
1562
|
+
* read it as a complete page). A page that isn't well formed (no `tools` array, a cursor
|
|
1563
|
+
* that isn't a string), or cursors that don't terminate (a repeat, or more
|
|
1564
|
+
* than {@link MAX_TOOL_LIST_PAGES} pages), are errors, never a partial or
|
|
1565
|
+
* empty inventory published as complete.
|
|
1566
|
+
*/
|
|
1567
|
+
async sendToolsList(): Promise<{ tools: McpToolDefinition[] }> {
|
|
1568
|
+
const tools: McpToolDefinition[] = [];
|
|
1569
|
+
const seen = new Set<string>();
|
|
1570
|
+
let cursor: string | undefined;
|
|
1571
|
+
let first: Record<string, unknown> | undefined;
|
|
1572
|
+
for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) {
|
|
1573
|
+
const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }) as Record<string, unknown> | null;
|
|
1574
|
+
const malformed = (why: string) =>
|
|
1575
|
+
new Error(`MCPL server "${this.id}" sent a malformed tools/list page ${page + 1}: ${why}`);
|
|
1576
|
+
if (!result || typeof result !== 'object' || !Array.isArray(result.tools)) {
|
|
1577
|
+
throw malformed('no tools array');
|
|
1578
|
+
}
|
|
1579
|
+
first ??= result;
|
|
1580
|
+
tools.push(...(result.tools as McpToolDefinition[]));
|
|
1581
|
+
if (result.nextCursor === undefined || result.nextCursor === null) {
|
|
1582
|
+
// The first page's other fields are kept as they came, so a
|
|
1583
|
+
// single-page answer is returned exactly as before.
|
|
1584
|
+
const { nextCursor: _done, ...rest } = first as { nextCursor?: unknown };
|
|
1585
|
+
return { ...rest, tools };
|
|
1586
|
+
}
|
|
1587
|
+
if (typeof result.nextCursor !== 'string') {
|
|
1588
|
+
throw malformed(`nextCursor must be a string, got ${JSON.stringify(result.nextCursor)}`);
|
|
1589
|
+
}
|
|
1590
|
+
if (seen.has(result.nextCursor)) {
|
|
1591
|
+
throw new Error(`MCPL server "${this.id}" repeated tools/list cursor ${JSON.stringify(result.nextCursor)}`);
|
|
1592
|
+
}
|
|
1593
|
+
seen.add(result.nextCursor);
|
|
1594
|
+
cursor = result.nextCursor;
|
|
1595
|
+
}
|
|
1596
|
+
throw new Error(`MCPL server "${this.id}" tools/list did not finish within ${MAX_TOOL_LIST_PAGES} pages`);
|
|
1028
1597
|
}
|
|
1029
1598
|
|
|
1030
1599
|
/** Send `tools/call` and return the result. Optionally includes state/checkpoint for stateful tools. */
|
|
@@ -1063,42 +1632,66 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1063
1632
|
// Lifecycle
|
|
1064
1633
|
// ==========================================================================
|
|
1065
1634
|
|
|
1066
|
-
/**
|
|
1067
|
-
|
|
1635
|
+
/** The requests still waiting for an answer, for the operator's log line
|
|
1636
|
+
* when the connection goes: their methods and ids. */
|
|
1637
|
+
private unansweredRequests(): string {
|
|
1638
|
+
const list = [...this.pendingRequests].map(([id, pending]) => `${pending.method} (id=${id})`).join(', ');
|
|
1639
|
+
return `${this.pendingRequests.size} request(s) unanswered: ${list}`;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Close the connection for good: disable reconnect, kill the child
|
|
1644
|
+
* process, and clean up resources. At once, as before, reconnecting stops
|
|
1645
|
+
* and pending requests fail; a reconnect handshake in flight is aborted.
|
|
1646
|
+
*
|
|
1647
|
+
* Calls share one attempt: while it is pending, and once it has
|
|
1648
|
+
* succeeded, close() returns it. The attempt waits for the aborted
|
|
1649
|
+
* reconnect's own cleanup, closes the transport (its close is the reaping
|
|
1650
|
+
* verdict) and retries each launch an earlier cleanup couldn't reap. It
|
|
1651
|
+
* rejects while any child can't be confirmed exited, with that failure
|
|
1652
|
+
* itself, or an AggregateError when there are several. A failed attempt is
|
|
1653
|
+
* cleared, so the next close() retries what it couldn't reap. 'close' is
|
|
1654
|
+
* emitted once, when the teardown of a connection the host closed while
|
|
1655
|
+
* live completes.
|
|
1656
|
+
*/
|
|
1657
|
+
close(): Promise<void> {
|
|
1068
1658
|
// Disable reconnect before closing — explicit close means stop retrying
|
|
1069
1659
|
this.reconnectEnabled = false;
|
|
1660
|
+
this.closedByThisHost = true;
|
|
1070
1661
|
if (this.reconnectTimer) {
|
|
1071
1662
|
clearTimeout(this.reconnectTimer);
|
|
1072
1663
|
this.reconnectTimer = null;
|
|
1073
1664
|
}
|
|
1074
|
-
|
|
1665
|
+
this.reconnecting?.abort.abort();
|
|
1075
1666
|
if (this.closePromise) return this.closePromise;
|
|
1076
|
-
if (this.closed && !this.closeIncomplete && !this.reconnectPromise) return;
|
|
1077
|
-
this.closed = true;
|
|
1078
|
-
this.closeIncomplete = true;
|
|
1079
|
-
this.resetPolicyForTransportBoundary();
|
|
1080
1667
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1668
|
+
if (!this.closed) {
|
|
1669
|
+
this.closed = true;
|
|
1670
|
+
this.closeIncomplete = true;
|
|
1671
|
+
this.resetPolicyForTransportBoundary();
|
|
1672
|
+
|
|
1673
|
+
// Reject all pending requests. Each message is the whole of what an
|
|
1674
|
+
// agent reads for a tools/call, and what follows a channel tool's own
|
|
1675
|
+
// words ("Failed to publish to channel: …") for the requests a channel
|
|
1676
|
+
// tool makes, so it names no method; the log line does. It doesn't say
|
|
1677
|
+
// whether this host reconnects: a restart closes the connection and then
|
|
1678
|
+
// connects again.
|
|
1679
|
+
if (this.pendingRequests.size > 0) {
|
|
1680
|
+
console.error(`MCPL server "${this.id}": this host closed its connection with ${this.unansweredRequests()}`);
|
|
1681
|
+
}
|
|
1682
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1683
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
1684
|
+
pending.reject(new McplRequestError(
|
|
1685
|
+
`The "${this.id}" server didn't answer before this host closed its connection, so whether the request took effect is unknown. ` +
|
|
1686
|
+
'Check its effect before trying again.',
|
|
1687
|
+
'no-response',
|
|
1688
|
+
));
|
|
1689
|
+
}
|
|
1690
|
+
this.pendingRequests.clear();
|
|
1691
|
+
this.orphanedRequests.clear();
|
|
1085
1692
|
}
|
|
1086
|
-
|
|
1087
|
-
this.
|
|
1088
|
-
|
|
1089
|
-
// Tear down the transport (kills the child / closes the socket). The
|
|
1090
|
-
// transport's own 'close' event is short-circuited by `this.closed` in
|
|
1091
|
-
// setupLifecycle, so this is the single source of the connection 'close'.
|
|
1092
|
-
const attempt = Promise.resolve().then(async () => {
|
|
1093
|
-
// An already opened handshake owns a transport too. It hands that
|
|
1094
|
-
// transport back without admission once reconnect has been disabled.
|
|
1095
|
-
// Only the registry's closeAll bounds its caller's wait for this; retry
|
|
1096
|
-
// reuses this real close attempt.
|
|
1097
|
-
if (this.reconnectPromise) await this.reconnectPromise;
|
|
1098
|
-
if (this.transport) await this.transport.close();
|
|
1099
|
-
this.closeIncomplete = false;
|
|
1100
|
-
this.emit('close', null, null, this.closeInfoByHost());
|
|
1101
|
-
});
|
|
1693
|
+
|
|
1694
|
+
const attempt = this.closeStep(this.transport, this.unreaped.map((held) => held.transport));
|
|
1102
1695
|
this.closePromise = attempt;
|
|
1103
1696
|
void attempt.catch(() => {
|
|
1104
1697
|
if (this.closePromise === attempt) this.closePromise = null;
|
|
@@ -1106,14 +1699,57 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1106
1699
|
return attempt;
|
|
1107
1700
|
}
|
|
1108
1701
|
|
|
1702
|
+
/** One close attempt. `heldBefore` are the launches earlier cleanups
|
|
1703
|
+
* couldn't reap, which it retries. */
|
|
1704
|
+
private async closeStep(transport: McplTransport | null, heldBefore: McplTransport[]): Promise<void> {
|
|
1705
|
+
// An aborted reconnect cleans up its own launch, holding it if it can't.
|
|
1706
|
+
// That cleanup was this attempt's try at it, so its failure is this
|
|
1707
|
+
// attempt's, not retried here.
|
|
1708
|
+
await this.reconnecting?.done;
|
|
1709
|
+
const heldMeanwhile = this.unreaped.filter((held) => !heldBefore.includes(held.transport));
|
|
1710
|
+
const failures: Error[] = heldMeanwhile.map((held) => held.failure);
|
|
1711
|
+
const targets: McplTransport[] = [];
|
|
1712
|
+
for (const target of [transport, ...heldBefore]) {
|
|
1713
|
+
if (target && !targets.includes(target) && !heldMeanwhile.some((held) => held.transport === target)) targets.push(target);
|
|
1714
|
+
}
|
|
1715
|
+
for (const target of targets) {
|
|
1716
|
+
try {
|
|
1717
|
+
// Kills the child / closes the socket.
|
|
1718
|
+
await target.close();
|
|
1719
|
+
this.unreaped = this.unreaped.filter((held) => held.transport !== target);
|
|
1720
|
+
} catch (error) {
|
|
1721
|
+
const failure = errorOf(error);
|
|
1722
|
+
failures.push(failure);
|
|
1723
|
+
if (!this.unreaped.some((held) => held.transport === target)) this.unreaped.push({ transport: target, failure });
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (failures.length === 1) throw failures[0];
|
|
1727
|
+
if (failures.length > 1) {
|
|
1728
|
+
throw new AggregateError(failures, `MCPL server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`);
|
|
1729
|
+
}
|
|
1730
|
+
// The transport's own 'close' event is short-circuited by `this.closed`
|
|
1731
|
+
// in setupLifecycle, so this is the single source of the connection
|
|
1732
|
+
// 'close' when the host tears down a live transport.
|
|
1733
|
+
if (this.closeIncomplete) {
|
|
1734
|
+
this.closeIncomplete = false;
|
|
1735
|
+
this.emit('close', null, null, this.closeInfoByHost());
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1109
1739
|
/**
|
|
1110
1740
|
* Tear down a live transport after an awareness-accounting failure without
|
|
1111
1741
|
* disabling configured auto-reconnect. The normal transport close handler
|
|
1112
1742
|
* rejects requests, emits lifecycle state, and schedules the next bounded
|
|
1113
|
-
* reconnect attempt. With reconnect disabled
|
|
1743
|
+
* reconnect attempt. With reconnect disabled the recycle ends the
|
|
1744
|
+
* connection for good, as close() does: 'close' comes once the child's
|
|
1745
|
+
* exit is confirmed, and a child it can't reap stays owned, holding the id.
|
|
1114
1746
|
*/
|
|
1115
1747
|
async reconnectAfterFailure(): Promise<void> {
|
|
1116
1748
|
this.pauseDataPlane();
|
|
1749
|
+
if (!this.reconnectEnabled) {
|
|
1750
|
+
await this.close();
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1117
1753
|
if (this.closed) {
|
|
1118
1754
|
this.scheduleReconnect();
|
|
1119
1755
|
return;
|
|
@@ -1167,73 +1803,118 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1167
1803
|
* Attempt to reconnect by opening a fresh transport and performing the
|
|
1168
1804
|
* handshake, then adopting that transport in place. Unlike the previous
|
|
1169
1805
|
* approach this builds no throwaway connection instance, so there are no
|
|
1170
|
-
* leaked listeners on a dead object.
|
|
1806
|
+
* leaked listeners on a dead object. The attempt is close()'s to abort,
|
|
1807
|
+
* and it settles only once its own launch is adopted, failed or cleaned up.
|
|
1808
|
+
* One attempt at a time: a request that arrives while one is in flight (a
|
|
1809
|
+
* recycle, say) starts nothing, since that attempt either adopts its
|
|
1810
|
+
* launch or schedules the next retry itself. A second attempt would take
|
|
1811
|
+
* close()'s ownership of the first (Iris-1827, room-293 #55410).
|
|
1171
1812
|
*/
|
|
1172
|
-
private attemptReconnect(): Promise<void> {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1813
|
+
private async attemptReconnect(): Promise<void> {
|
|
1814
|
+
// A queued recovery timer may outlive an earlier successful handshake:
|
|
1815
|
+
// only a connection that is down reconnects, so it never replaces a live
|
|
1816
|
+
// transport.
|
|
1817
|
+
if (this.reconnecting || !this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
|
|
1818
|
+
const abort = new AbortController();
|
|
1819
|
+
const done = this.reconnectOnce(this.config, this.hostCapabilities, abort.signal);
|
|
1820
|
+
this.reconnecting = { abort, done };
|
|
1821
|
+
await done;
|
|
1822
|
+
if (this.reconnecting?.done === done) this.reconnecting = null;
|
|
1179
1823
|
}
|
|
1180
1824
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1825
|
+
/** One reconnect attempt. It never rejects: each outcome is an event, a
|
|
1826
|
+
* scheduled retry, or a launch held as unreaped. */
|
|
1827
|
+
private async reconnectOnce(
|
|
1828
|
+
config: McplServerConfig,
|
|
1829
|
+
hostCapabilities: McplHostCapabilities,
|
|
1830
|
+
signal: AbortSignal,
|
|
1831
|
+
): Promise<void> {
|
|
1186
1832
|
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
1187
1833
|
// reports attempt N (reconnectAttempts counts failures so far).
|
|
1188
1834
|
const attempt = Math.max(1, this.reconnectAttempts);
|
|
1189
1835
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
// It isn't wired, and the handshake has removed its own listeners, so
|
|
1201
|
-
// an error it reports from here on, such as a malformed frame from a
|
|
1202
|
-
// WebSocket server while close() closes it, is logged here rather than
|
|
1203
|
-
// thrown where nothing catches it.
|
|
1204
|
-
transport.on('error', (err: Error) => {
|
|
1205
|
-
console.error(`[mcpl] ${this.id}: a transport kept for close() reported an error: ${err.message}`);
|
|
1206
|
-
});
|
|
1207
|
-
this.transport = transport;
|
|
1836
|
+
// The previous launch must be gone before another starts. A transport
|
|
1837
|
+
// the host closed itself (reconnectAfterFailure) may still be reaping its
|
|
1838
|
+
// child; one whose child already exited resolves at once.
|
|
1839
|
+
const previous = this.transport;
|
|
1840
|
+
if (previous) {
|
|
1841
|
+
try {
|
|
1842
|
+
await previous.close();
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
this.holdUnreaped(previous, errorOf(error));
|
|
1845
|
+
if (!signal.aborted) this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt, permanent: true });
|
|
1208
1846
|
return;
|
|
1209
1847
|
}
|
|
1848
|
+
}
|
|
1849
|
+
if (signal.aborted) return;
|
|
1210
1850
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
// to establish the server while data remains held.
|
|
1215
|
-
this.pauseDataPlane();
|
|
1216
|
-
this.resetPolicyForTransportBoundary();
|
|
1217
|
-
this.transport = transport;
|
|
1218
|
-
this.capabilities = capabilities;
|
|
1219
|
-
this.droppedCapabilities = droppedCapabilities;
|
|
1220
|
-
this.mcpToolsAdvertised = mcpToolsAdvertised;
|
|
1221
|
-
this.allowHostCommands = this.config?.allowHostCommands === true;
|
|
1222
|
-
this.closed = false;
|
|
1223
|
-
this.nextRequestId = 1;
|
|
1224
|
-
this.pendingRequests.clear();
|
|
1225
|
-
this.wireTransport(transport);
|
|
1226
|
-
|
|
1227
|
-
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
1228
|
-
this.reconnectAttempts = 0;
|
|
1229
|
-
this.emit('reconnect', { attempts: attempt });
|
|
1851
|
+
let established: Awaited<ReturnType<typeof McplServerConnection.handshake>>;
|
|
1852
|
+
try {
|
|
1853
|
+
established = await McplServerConnection.handshake(config, hostCapabilities, signal);
|
|
1230
1854
|
} catch (error) {
|
|
1231
|
-
|
|
1855
|
+
const unreaped = error instanceof HandshakeCleanupError;
|
|
1856
|
+
if (unreaped) this.holdUnreaped(error.transport, error.cleanup);
|
|
1857
|
+
// Closed by the host meanwhile: not a failed attempt.
|
|
1858
|
+
if (signal.aborted) return;
|
|
1859
|
+
const failure = unreaped ? error.failure : errorOf(error);
|
|
1860
|
+
const redact = (text: string) => redactServerText(text, this.config ?? {});
|
|
1861
|
+
console.error(`MCPL server "${this.id}" reconnect failed: ${redact(failure.message)}${withStderr(failure, redact)}`);
|
|
1232
1862
|
this.reconnectAttempts = attempt + 1;
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1863
|
+
// No common protocol revision: every further attempt would restart the
|
|
1864
|
+
// server to hear the same answer. Stop retrying; the configuration is
|
|
1865
|
+
// what has to change. A launch that couldn't be reaped halts retrying
|
|
1866
|
+
// for good: close() can confirm its exit, but close() is terminal, so
|
|
1867
|
+
// nothing resumes this connection. Every other failure keeps the
|
|
1868
|
+
// backoff loop.
|
|
1869
|
+
if (error instanceof McplProtocolVersionError) this.reconnectEnabled = false;
|
|
1870
|
+
const permanent = unreaped || error instanceof McplProtocolVersionError;
|
|
1871
|
+
const message = unreaped ? `${failure.message}; ${this.unreapedFailure()}` : failure.message;
|
|
1872
|
+
const stderrTail = stderrTailOf(failure);
|
|
1873
|
+
this.emit('reconnect-failed', { error: message, ...(stderrTail ? { stderrTail } : {}), attempt, ...(permanent ? { permanent: true } : {}) });
|
|
1874
|
+
if (!permanent) this.scheduleReconnect();
|
|
1875
|
+
return;
|
|
1236
1876
|
}
|
|
1877
|
+
const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = established;
|
|
1878
|
+
|
|
1879
|
+
if (signal.aborted) {
|
|
1880
|
+
// Closed by the host while the handshake finished: this launch never
|
|
1881
|
+
// serves. close() is waiting on this attempt, so it hears the verdict.
|
|
1882
|
+
// The transport isn't wired, and the handshake has taken its own
|
|
1883
|
+
// listeners off, so an error it reports while it closes, such as a
|
|
1884
|
+
// malformed frame from a WebSocket server, is logged here rather than
|
|
1885
|
+
// thrown where nothing catches it.
|
|
1886
|
+
transport.on('error', (err: Error) => {
|
|
1887
|
+
console.error(`[mcpl] ${this.id}: a transport closed before it served reported an error: ${redactServerText(err.message, config)}`);
|
|
1888
|
+
});
|
|
1889
|
+
await transport.close().catch((error: unknown) => this.holdUnreaped(transport, errorOf(error)));
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Close both planes before the new transport can emit, as at initial
|
|
1894
|
+
// connect: its traffic is admitted against the grant, which this
|
|
1895
|
+
// boundary resets, so control traffic too (a server's channels/register
|
|
1896
|
+
// right after initialize) must wait for the new grant rather than be
|
|
1897
|
+
// refused against the empty one. The reconnect lifecycle event bypasses
|
|
1898
|
+
// buffering; its framework listener installs the awareness barrier,
|
|
1899
|
+
// re-establishes the grant (§5.3 policy responses are never buffered),
|
|
1900
|
+
// and then opens the planes.
|
|
1901
|
+
this.controlPlaneReady = false;
|
|
1902
|
+
this.dataPlaneReady = false;
|
|
1903
|
+
this.resetPolicyForTransportBoundary();
|
|
1904
|
+
this.transport = transport;
|
|
1905
|
+
this.capabilities = capabilities;
|
|
1906
|
+
this.droppedCapabilities = droppedCapabilities;
|
|
1907
|
+
this.mcpToolsAdvertised = mcpToolsAdvertised;
|
|
1908
|
+
this.protocolVersion = protocolVersion;
|
|
1909
|
+
this.allowHostCommands = this.config?.allowHostCommands === true;
|
|
1910
|
+
this.closed = false;
|
|
1911
|
+
this.nextRequestId = 1;
|
|
1912
|
+
this.pendingRequests.clear();
|
|
1913
|
+
this.wireTransport(transport);
|
|
1914
|
+
|
|
1915
|
+
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
1916
|
+
this.reconnectAttempts = 0;
|
|
1917
|
+
this.emit('reconnect', { attempts: attempt });
|
|
1237
1918
|
}
|
|
1238
1919
|
|
|
1239
1920
|
// ==========================================================================
|
|
@@ -1257,7 +1938,34 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1257
1938
|
options: RequestOptions = {},
|
|
1258
1939
|
): Promise<unknown> {
|
|
1259
1940
|
if (this.closed) {
|
|
1260
|
-
|
|
1941
|
+
// After this host's own close, which a restart makes before connecting
|
|
1942
|
+
// again and a removal makes for good, only that this host closed it
|
|
1943
|
+
// holds, and one next step holds for both: calling again reaches the
|
|
1944
|
+
// restarted server, or fails where it's gone.
|
|
1945
|
+
return Promise.reject(new McplRequestError(this.closedByThisHost
|
|
1946
|
+
? `The request wasn't sent: this host closed its connection to the "${this.id}" server, so nothing ran. ` +
|
|
1947
|
+
'If calling it again fails, ask whoever runs this host.'
|
|
1948
|
+
: `The request wasn't sent: the connection to the "${this.id}" server is closed, so nothing ran. ` +
|
|
1949
|
+
(this.willReconnect
|
|
1950
|
+
? 'This host is reconnecting to it; try again later.'
|
|
1951
|
+
: "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."),
|
|
1952
|
+
'not-sent',
|
|
1953
|
+
));
|
|
1954
|
+
}
|
|
1955
|
+
// Once the child's stdin or stdout has failed, the transport is ending
|
|
1956
|
+
// it and writes nothing more to it, so a request is refused here as not
|
|
1957
|
+
// sent rather than left to wait out its timeout. The method and the
|
|
1958
|
+
// pipe's error are the operator's, so they go to the log.
|
|
1959
|
+
const pipeFailure = this.transport?.pipeFailure;
|
|
1960
|
+
if (pipeFailure) {
|
|
1961
|
+
console.error(`MCPL server "${this.id}": ${method} not sent, since its stdio pipe failed: ${logDetail(pipeFailure.message)}`);
|
|
1962
|
+
return Promise.reject(new McplRequestError(
|
|
1963
|
+
`The request wasn't sent: this host can no longer write to the "${this.id}" server, so nothing ran. ` +
|
|
1964
|
+
(this.willReconnect
|
|
1965
|
+
? 'This host is reconnecting to it; try again later.'
|
|
1966
|
+
: "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."),
|
|
1967
|
+
'not-sent',
|
|
1968
|
+
));
|
|
1261
1969
|
}
|
|
1262
1970
|
|
|
1263
1971
|
const id = this.nextRequestId++;
|
|
@@ -1290,13 +1998,17 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1290
1998
|
});
|
|
1291
1999
|
if (this.orphanedRequests.size > 4096) this.orphanedRequests.delete(this.orphanedRequests.keys().next().value!);
|
|
1292
2000
|
}
|
|
2001
|
+
// The method and id are the operator's, so they go to the log.
|
|
2002
|
+
console.error(
|
|
2003
|
+
`MCPL server "${this.id}" did not respond to ${method} (id=${id}) within ${timeoutMs}ms; ` +
|
|
2004
|
+
'the request was abandoned here, not cancelled',
|
|
2005
|
+
);
|
|
2006
|
+
const seconds = timeoutMs / 1000;
|
|
1293
2007
|
reject(new McplRequestError(
|
|
1294
|
-
`
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
`state before retrying; a blind retry of a stateful/side-effecting ` +
|
|
1299
|
-
`tool may duplicate it.`,
|
|
2008
|
+
`The "${this.id}" server didn't answer within ${seconds} second${seconds === 1 ? '' : 's'}, ` +
|
|
2009
|
+
'so whether the request took effect is unknown: ' +
|
|
2010
|
+
'it was abandoned here, not cancelled, and may still complete on the server. ' +
|
|
2011
|
+
'Check its effect before trying again, since repeating something that changes state may do it twice.',
|
|
1300
2012
|
'no-response',
|
|
1301
2013
|
));
|
|
1302
2014
|
}, timeoutMs);
|
|
@@ -1308,11 +2020,15 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1308
2020
|
this.transport!.writeLine(JSON.stringify(request));
|
|
1309
2021
|
} catch (err) {
|
|
1310
2022
|
// A synchronous write rejection sent no request. Remove its pending
|
|
1311
|
-
// state and watchdog before reporting that it is safe to retry.
|
|
2023
|
+
// state and watchdog before reporting that it is safe to retry. The
|
|
2024
|
+
// method, id and the transport's words are the operator's.
|
|
1312
2025
|
this.pendingRequests.delete(id);
|
|
1313
2026
|
if (pending.timer) clearTimeout(pending.timer);
|
|
2027
|
+
console.error(
|
|
2028
|
+
`MCPL server "${this.id}": writing ${method} (id=${id}) failed: ${logDetail(err instanceof Error ? err.message : err)}`,
|
|
2029
|
+
);
|
|
1314
2030
|
reject(new McplRequestError(
|
|
1315
|
-
`
|
|
2031
|
+
`The request wasn't sent: this host couldn't write it to the "${this.id}" server, so nothing ran. Try again later.`,
|
|
1316
2032
|
'not-sent',
|
|
1317
2033
|
));
|
|
1318
2034
|
}
|
|
@@ -1414,6 +2130,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1414
2130
|
* Wire up the transport's line stream to route incoming JSON-RPC messages.
|
|
1415
2131
|
*/
|
|
1416
2132
|
private setupMessageRouting(transport: McplTransport): void {
|
|
2133
|
+
// The boundary this transport serves within: a line read once it has
|
|
2134
|
+
// passed is the dead process's (afterBoundary).
|
|
2135
|
+
const epoch = this.transportEpoch;
|
|
1417
2136
|
transport.on('line', (line: string) => {
|
|
1418
2137
|
let msg: JsonRpcRequest | JsonRpcResponse;
|
|
1419
2138
|
try {
|
|
@@ -1427,7 +2146,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1427
2146
|
// before property access, just as malformed JSON lines are ignored.
|
|
1428
2147
|
if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) return;
|
|
1429
2148
|
try {
|
|
1430
|
-
this.dispatchMessage(msg);
|
|
2149
|
+
this.dispatchMessage(msg, epoch);
|
|
1431
2150
|
} catch (error) {
|
|
1432
2151
|
const request = msg as { method?: unknown; id?: unknown };
|
|
1433
2152
|
this.reportInboundFailure(error, {
|
|
@@ -1439,16 +2158,23 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1439
2158
|
}
|
|
1440
2159
|
|
|
1441
2160
|
/** Contain synchronous dispatch failures; async RPC handlers own their promises. */
|
|
1442
|
-
private dispatchMessage(msg: JsonRpcRequest | JsonRpcResponse): void {
|
|
2161
|
+
private dispatchMessage(msg: JsonRpcRequest | JsonRpcResponse, epoch: number): void {
|
|
1443
2162
|
// Is this a response to one of our outbound requests?
|
|
1444
2163
|
if ('id' in msg && msg.id != null && !('method' in msg)) {
|
|
1445
|
-
this.handleResponse(msg as JsonRpcResponse);
|
|
2164
|
+
this.handleResponse(msg as JsonRpcResponse, epoch !== this.transportEpoch);
|
|
1446
2165
|
return;
|
|
1447
2166
|
}
|
|
1448
2167
|
|
|
1449
2168
|
// It is an inbound request or notification from the server
|
|
1450
2169
|
const request = msg as JsonRpcRequest;
|
|
1451
2170
|
|
|
2171
|
+
// Read after its transport's boundary, it is the dead process's: the
|
|
2172
|
+
// boundary's rule applies (afterBoundary).
|
|
2173
|
+
if (epoch !== this.transportEpoch) {
|
|
2174
|
+
this.afterBoundary(request, epoch);
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
1452
2178
|
// MCP's ping, which either side may send: its receiver answers promptly
|
|
1453
2179
|
// with an empty result. It's answered as it arrives, before admission and
|
|
1454
2180
|
// buffering, since it asks only whether the connection is alive, and a
|
|
@@ -1506,18 +2232,55 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1506
2232
|
// Emit the typed event with params and (for requests) a respond callback
|
|
1507
2233
|
if (request.id != null) {
|
|
1508
2234
|
// Server expects a response — provide a respond helper
|
|
1509
|
-
this.dispatchInboundEvent(eventName, request.params,
|
|
1510
|
-
id: request.id,
|
|
1511
|
-
respond: (result: unknown) => this.sendResponse(request.id!, result),
|
|
1512
|
-
respondError: (code: number, message: string, data?: unknown) =>
|
|
1513
|
-
this.sendErrorResponse(request.id!, code, message, data),
|
|
1514
|
-
});
|
|
2235
|
+
this.dispatchInboundEvent(eventName, request.params, this.responderFor(request.id, epoch));
|
|
1515
2236
|
} else {
|
|
1516
2237
|
// Notification — no response expected
|
|
1517
2238
|
this.dispatchInboundEvent(eventName, request.params);
|
|
1518
2239
|
}
|
|
1519
2240
|
}
|
|
1520
2241
|
|
|
2242
|
+
/**
|
|
2243
|
+
* A request's responder, bound to the transport it came on (`epoch`): an
|
|
2244
|
+
* answer, or a streamed inference's chunk, sent after that transport has
|
|
2245
|
+
* closed reaches nothing, never a server that didn't ask it.
|
|
2246
|
+
*/
|
|
2247
|
+
private responderFor(id: string | number, epoch: number) {
|
|
2248
|
+
return {
|
|
2249
|
+
id,
|
|
2250
|
+
respond: (result: unknown) => this.sendResponse(id, result, epoch),
|
|
2251
|
+
respondError: (code: number, message: string, data?: unknown) =>
|
|
2252
|
+
this.sendErrorResponse(id, code, message, data, epoch),
|
|
2253
|
+
sendChunk: (params: InferenceChunkParams) => this.sendInferenceChunk(params, epoch),
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* A request or notification read from a transport after its boundary has
|
|
2259
|
+
* passed: the boundary's rule (resetPolicyForTransportBoundary). An
|
|
2260
|
+
* incoming message or push is held as kept, for the next transport's
|
|
2261
|
+
* grant, and any other is dropped and logged. (A response read then goes to
|
|
2262
|
+
* handleResponse, flagged late.) A stdio
|
|
2263
|
+
* server's transport closes on the child's 'exit', when its stdout may
|
|
2264
|
+
* still be open, as it is while a process the server started holds it, and
|
|
2265
|
+
* it is read until the next reconnect attempt closes its reader. No
|
|
2266
|
+
* transport serves meanwhile, so what is held waits for the next grant
|
|
2267
|
+
* (flushHeld). A connection closed for good, which won't reconnect, reads
|
|
2268
|
+
* it while its writer lives, with no grant to come, so it drops and logs
|
|
2269
|
+
* all of it; hold's bounds cap what any other keeps.
|
|
2270
|
+
*/
|
|
2271
|
+
private afterBoundary(request: JsonRpcRequest, epoch: number): void {
|
|
2272
|
+
const event = typeof request.method === 'string' && Object.hasOwn(McplServerConnection.METHOD_TO_EVENT, request.method)
|
|
2273
|
+
? McplServerConnection.METHOD_TO_EVENT[request.method] : undefined;
|
|
2274
|
+
if (this.closedForGood || event === undefined || !McplServerConnection.WORLD_EVENTS.has(event)) {
|
|
2275
|
+
console.error(
|
|
2276
|
+
`[mcpl] ${this.id}: dropped a message read after its transport closed` +
|
|
2277
|
+
`${this.closedForGood ? ', with no transport to come' : ''}: ${describeMethod(request.method)}`,
|
|
2278
|
+
);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
this.hold(event, request.id != null ? [request.params, this.responderFor(request.id, epoch)] : [request.params], true);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
1521
2284
|
/** Live delivery and delayed plane flushes share the same containment boundary. */
|
|
1522
2285
|
private dispatchInboundEvent(event: string, ...args: unknown[]): void {
|
|
1523
2286
|
try {
|
|
@@ -1570,8 +2333,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1570
2333
|
/**
|
|
1571
2334
|
* Handle a JSON-RPC response by resolving/rejecting the corresponding pending request.
|
|
1572
2335
|
*/
|
|
1573
|
-
private handleResponse(response: JsonRpcResponse): void {
|
|
1574
|
-
|
|
2336
|
+
private handleResponse(response: JsonRpcResponse, late = false): void {
|
|
2337
|
+
// One read after its transport's boundary answers a request the close
|
|
2338
|
+
// ended. It's matched against none of this connection's request records,
|
|
2339
|
+
// since the next transport numbers its requests afresh, and goes the way
|
|
2340
|
+
// of any late answer: a stateful one is surfaced as an orphaned response.
|
|
2341
|
+
const pending = late ? undefined : this.pendingRequests.get(response.id);
|
|
1575
2342
|
if (!pending) {
|
|
1576
2343
|
// Orphaned response — the request already timed out (or was settled) and
|
|
1577
2344
|
// was removed from pendingRequests. A late STATEFUL result must be
|
|
@@ -1579,8 +2346,8 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1579
2346
|
// Selected side-effecting calls are also surfaced even without state so
|
|
1580
2347
|
// their outcome-unknown receipt remains visible. We cannot safely
|
|
1581
2348
|
// re-inject either result because the original dispatch context is gone.
|
|
1582
|
-
const orphaned = this.orphanedRequests.get(response.id);
|
|
1583
|
-
this.orphanedRequests.delete(response.id);
|
|
2349
|
+
const orphaned = late ? undefined : this.orphanedRequests.get(response.id);
|
|
2350
|
+
if (!late) this.orphanedRequests.delete(response.id);
|
|
1584
2351
|
// A push/render answered after its timeout (RFC-006 §5.3) is reported
|
|
1585
2352
|
// with the render's featureSet, key, eventId and, for a channel-scoped
|
|
1586
2353
|
// subject, channelId, kept from its request when the timeout fired, so
|
|
@@ -1622,19 +2389,32 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1622
2389
|
if (pending.timer) clearTimeout(pending.timer);
|
|
1623
2390
|
|
|
1624
2391
|
if (response.error) {
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
//
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
2392
|
+
// The server's own words, after a word on whose they are, bounded as
|
|
2393
|
+
// errors.ts says, since they're the server's text. Only a string is
|
|
2394
|
+
// words: a message of any other JSON value reads as none. The method,
|
|
2395
|
+
// id and JSON-RPC code are the operator's, so they go to the log, which
|
|
2396
|
+
// prints any value a server can send without throwing (logValue). The
|
|
2397
|
+
// caller's text keeps the server's words unredacted, as a JSON-RPC
|
|
2398
|
+
// answer passes (redactServerText's scope); the log is a record of the
|
|
2399
|
+
// server's text, so the message is redacted there, whatever JSON value
|
|
2400
|
+
// it is, as every other line this connection writes is.
|
|
2401
|
+
const said = typeof response.error.message === 'string' ? boundedForLog(response.error.message.trim()) : '';
|
|
2402
|
+
// The call is settled before its log line: its entry is gone and its
|
|
2403
|
+
// timer cleared, so nothing else would settle it, and a host's logger
|
|
2404
|
+
// can throw. The dispatch's containment reports such a throw. (A
|
|
2405
|
+
// configuration that can't be read withholds the message rather than
|
|
2406
|
+
// throw: redactServerText.)
|
|
2407
|
+
pending.reject(new McplRequestError(
|
|
2408
|
+
said
|
|
2409
|
+
? `The "${this.id}" server answered with an error: ${said}`
|
|
2410
|
+
: `The "${this.id}" server answered with an error without saying what went wrong, so whether the request took effect is unknown. ` +
|
|
2411
|
+
'Check its effect before trying again.',
|
|
2412
|
+
'error-response',
|
|
2413
|
+
(response.error as { data?: unknown }).data,
|
|
2414
|
+
));
|
|
2415
|
+
console.error(
|
|
2416
|
+
`MCPL server "${this.id}" answered ${pending.method} (id=${response.id}) with error ${logValue(response.error.code)}: ` +
|
|
2417
|
+
logValueRedacted(response.error.message, (text) => redactServerText(text, this.config ?? {})),
|
|
1638
2418
|
);
|
|
1639
2419
|
} else {
|
|
1640
2420
|
pending.resolve(response.result);
|
|
@@ -1644,17 +2424,38 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1644
2424
|
/**
|
|
1645
2425
|
* Send a successful JSON-RPC response back to the server.
|
|
1646
2426
|
*/
|
|
1647
|
-
private sendResponse(id: string | number, result: unknown): void {
|
|
1648
|
-
|
|
2427
|
+
private sendResponse(id: string | number, result: unknown, epoch = this.transportEpoch): void {
|
|
2428
|
+
// A closed connection's epoch is past every request's, so a refusal
|
|
2429
|
+
// while it is closed is logged too.
|
|
2430
|
+
if (!this.sameTransport(id, epoch) || this.closed) return;
|
|
1649
2431
|
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
|
|
1650
2432
|
this.transport?.writeLine(JSON.stringify(response));
|
|
1651
2433
|
}
|
|
1652
2434
|
|
|
2435
|
+
/** Whether a response to a request received on `epoch` may go out now.
|
|
2436
|
+
* The id is the server's, so it's logged as a field; `answer`, which the
|
|
2437
|
+
* caller has formatted for the log, ends the line. */
|
|
2438
|
+
private sameTransport(id: string | number, epoch: number, answer?: string): boolean {
|
|
2439
|
+
if (epoch === this.transportEpoch) return true;
|
|
2440
|
+
console.error(
|
|
2441
|
+
`[mcpl] ${this.id}: not answering request ${logValue(id)}: the transport it came on has closed${answer ? `, with ${answer}` : ''}`,
|
|
2442
|
+
);
|
|
2443
|
+
return false;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
1653
2446
|
/**
|
|
1654
2447
|
* Send a JSON-RPC error response back to the server.
|
|
1655
2448
|
*/
|
|
1656
|
-
private sendErrorResponse(
|
|
1657
|
-
|
|
2449
|
+
private sendErrorResponse(
|
|
2450
|
+
id: string | number,
|
|
2451
|
+
code: number,
|
|
2452
|
+
message: string,
|
|
2453
|
+
data?: unknown,
|
|
2454
|
+
epoch = this.transportEpoch,
|
|
2455
|
+
): void {
|
|
2456
|
+
// A refusal that can't be answered still says what it refused: a held
|
|
2457
|
+
// message admitted under the next transport's grant is refused here.
|
|
2458
|
+
if (!this.sameTransport(id, epoch, `error ${code} ${logDetail(message)}`) || this.closed) return;
|
|
1658
2459
|
const response: JsonRpcResponse = {
|
|
1659
2460
|
jsonrpc: '2.0',
|
|
1660
2461
|
id,
|
|
@@ -1679,17 +2480,33 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1679
2480
|
if (!this.emitToErrorListeners(error)) console.error(`[mcpl] ${this.id}: transport error: ${logDetail(err.message)}`);
|
|
1680
2481
|
});
|
|
1681
2482
|
|
|
1682
|
-
|
|
2483
|
+
const lost = (info: TransportCloseInfo) => {
|
|
1683
2484
|
if (!this.closed) {
|
|
1684
2485
|
this.closed = true;
|
|
1685
2486
|
this.resetPolicyForTransportBoundary();
|
|
2487
|
+
// A teardown the host asked for via reconnectAfterFailure() arrives
|
|
2488
|
+
// here too; it's reported as such below.
|
|
2489
|
+
const byHost = this.hostCloseRequested;
|
|
2490
|
+
this.hostCloseRequested = false;
|
|
1686
2491
|
|
|
1687
|
-
// Reject all pending requests
|
|
1688
|
-
|
|
2492
|
+
// Reject all pending requests. The words hold whether the server
|
|
2493
|
+
// dropped or this host closed the transport to recover; how it closed
|
|
2494
|
+
// is the operator's, so it goes to the log, not into the messages.
|
|
2495
|
+
if (this.pendingRequests.size > 0) {
|
|
2496
|
+
// Whatever the host asked for, the close's own details are kept, so
|
|
2497
|
+
// a drop that races a recycle loses nothing.
|
|
2498
|
+
const how = `${byHost ? 'closed by this host, ' : ''}` +
|
|
2499
|
+
`code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${closeReasonText(info)}`;
|
|
2500
|
+
console.error(`MCPL server "${this.id}" disconnected (${how}) with ${this.unansweredRequests()}`);
|
|
2501
|
+
}
|
|
2502
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1689
2503
|
if (pending.timer) clearTimeout(pending.timer);
|
|
1690
2504
|
pending.reject(
|
|
1691
2505
|
new McplRequestError(
|
|
1692
|
-
`
|
|
2506
|
+
`The connection to the "${this.id}" server closed before it answered, so whether the request took effect is unknown. ` +
|
|
2507
|
+
(this.reconnectEnabled
|
|
2508
|
+
? 'Check its effect before trying again; this host is reconnecting to it.'
|
|
2509
|
+
: "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."),
|
|
1693
2510
|
'no-response',
|
|
1694
2511
|
),
|
|
1695
2512
|
);
|
|
@@ -1699,10 +2516,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1699
2516
|
|
|
1700
2517
|
// Forward the transport's close info so listeners can tell a server
|
|
1701
2518
|
// that refused the credential (WebSocket 4001) from a crash, and act
|
|
1702
|
-
// before the reconnect below redials.
|
|
1703
|
-
// via reconnectAfterFailure() arrives here too; report it as such.
|
|
1704
|
-
const byHost = this.hostCloseRequested;
|
|
1705
|
-
this.hostCloseRequested = false;
|
|
2519
|
+
// before the reconnect below redials.
|
|
1706
2520
|
const closeInfo: McplConnectionCloseInfo = {
|
|
1707
2521
|
transport: transport.kind,
|
|
1708
2522
|
code: info.code ?? null,
|
|
@@ -1718,6 +2532,21 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1718
2532
|
this.scheduleReconnect();
|
|
1719
2533
|
}
|
|
1720
2534
|
}
|
|
2535
|
+
};
|
|
2536
|
+
transport.on('close', lost);
|
|
2537
|
+
// A stdio child whose stdin or stdout failed, and that outlived SIGKILL
|
|
2538
|
+
// too: the transport stays open, owning the child. The connection holds
|
|
2539
|
+
// it as a launch it can't reap, as a reconnect does, which halts
|
|
2540
|
+
// reconnecting, and is lost as on a close, with the permanent failure
|
|
2541
|
+
// that raises the alert and takes its tools off the list. Its id stays
|
|
2542
|
+
// held until close() confirms the exit. A close the host has begun
|
|
2543
|
+
// reports the failure itself. During a handshake nothing listens yet:
|
|
2544
|
+
// the handshake's own cleanup holds the launch, once the handshake fails.
|
|
2545
|
+
transport.on('unreaped', (failure: Error) => {
|
|
2546
|
+
if (this.closed || this.transport !== transport) return;
|
|
2547
|
+
this.holdUnreaped(transport, failure);
|
|
2548
|
+
lost({ reason: `its stdio pipe failed with ${transport.pipeFailure?.message ?? 'an error'}, and its child outlived SIGKILL` });
|
|
2549
|
+
this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt: Math.max(1, this.reconnectAttempts), permanent: true });
|
|
1721
2550
|
});
|
|
1722
2551
|
}
|
|
1723
2552
|
}
|