@animalabs/agent-framework 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -1
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. 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`, one marked line per line of it,
90
- * each through log-value, since it's the server's text. '' when there's
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 an error message gives it: the transport's
99
- * own words. A WebSocket peer's close reason is the server's text
100
- * (`reasonFromPeer`), so an error gives the words the transport uses for a
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 error gives it too:
104
- * a pending request's error has `code=`, and the handshake's adds it for any
105
- * WebSocket close, since a close before the handshake completes reaches no
106
- * 'close' event. A stdio child's exit reads as before. */
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
- const MCP_PROTOCOL_VERSION = '2024-11-05';
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: Array<{ event: string; args: unknown[] }> = [];
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
- * Does nothing once the connection is closed for good. Its policy was reset
403
- * when it closed, so what it held that needs a grant would be refused, and a
404
- * closed connection sends no response, so nothing it held could be answered.
405
- * Only what needs no grant would pass, to listeners of a server that won't
406
- * be served again. A connection between retries isn't closed for good: what
407
- * it held, such as a reconnect stub's connect-failed, still reaches its
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) return;
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.bufferedEvents = [];
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 (const { event, args } of pending) {
429
- // Re-enter the gate for every buffered item. A control handler (notably
430
- // tools/list_changed) may install a newer data-plane barrier while this
431
- // snapshot is being flushed; later data items must observe that pause.
432
- this.dispatchInboundEvent(event, ...args);
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}, it does nothing once the connection is closed for good.
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) return;
800
+ if (this.closedForGood) {
801
+ this.deliverOwnReports();
802
+ return;
803
+ }
445
804
  this.controlPlaneReady = true;
446
805
  const pending = this.bufferedEvents;
447
- this.bufferedEvents = [];
448
- this.heldRequests = 0;
449
- for (const item of pending) {
450
- // Re-enter the ADMISSION gate (this.emit), never super.emit: control
451
- // events include channels-register/changed, which are grant-gated —
452
- // the old direct flush bypassed positive-grant enforcement entirely
453
- // (PR #79 review blocker 3). Data-plane items re-buffer in order via
454
- // the same call, since dataPlaneReady is still false.
455
- this.dispatchInboundEvent(item.event, ...item.args);
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 reconnect adoption and list-change
462
- * reconciliation so no new inference event can beat barrier installation.
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 flush takes the whole buffer and zeroes its
548
- // count, so what it holds again was admitted within these bounds and
549
- // stays within them.
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 transport can't be opened, the server
640
- * closes/errors before the handshake, or the handshake times out.
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
- const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised } = await McplServerConnection.handshake(config, hostCapabilities);
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
- // Store config for reconnection. Only `true` turns reconnect on, as at
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
- ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet<string>; mcpToolsAdvertised: boolean }> {
678
- const transport = await openTransport(config);
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 and
703
- // a timeout. All three paths clean up their listeners.
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(new Error(`MCPL server "${config.id}" initialize error: ${boundedForLog(describeCause(msg.error.message))}`));
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
- await transport.close().catch(() => { /* best effort */ });
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.connect(config, hostCapabilities);
1337
+ return await McplServerConnection.connectChecked(config, hostCapabilities, signal);
831
1338
  } catch (error) {
832
- if (config.reconnect !== true) {
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 that will reconnect in the background.
850
- * Used when initial connect fails and reconnect is enabled.
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
- // Schedule background reconnect
869
- stub.scheduleReconnect();
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
- return stub;
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
- sendInferenceChunk(params: InferenceChunkParams): void {
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
- /** Send `tools/list` and return the server's tool definitions. */
1026
- sendToolsList(): Promise<{ tools: McpToolDefinition[] }> {
1027
- return this.sendRequest('tools/list', {}) as Promise<{ tools: McpToolDefinition[] }>;
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
- /** Close the connection: disable reconnect, kill the child process, and clean up resources. */
1067
- async close(): Promise<void> {
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
- // Reject all pending requests
1082
- for (const [id, pending] of this.pendingRequests) {
1083
- if (pending.timer) clearTimeout(pending.timer);
1084
- pending.reject(new McplRequestError(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`, 'no-response'));
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
- this.pendingRequests.clear();
1087
- this.orphanedRequests.clear();
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 this leaves the server closed.
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
- if (this.reconnectPromise) return this.reconnectPromise;
1174
- const attempt = this.reconnectOnce();
1175
- this.reconnectPromise = attempt;
1176
- const clear = () => { if (this.reconnectPromise === attempt) this.reconnectPromise = null; };
1177
- void attempt.then(clear, clear);
1178
- return attempt;
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
- private async reconnectOnce(): Promise<void> {
1182
- // A queued recovery timer may outlive an earlier successful handshake.
1183
- // Recheck disconnection before opening or replacing any owned transport.
1184
- if (!this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
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
- try {
1191
- const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised } = await McplServerConnection.handshake(
1192
- this.config,
1193
- this.hostCapabilities,
1194
- );
1195
-
1196
- if (!this.reconnectEnabled) {
1197
- // The prior transport was disconnected before this retry began. Keep
1198
- // the fresh, unadmitted transport owned by close(), including on a
1199
- // failed transport cleanup, instead of reviving a stopped connection.
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
- // Close the data plane before the new transport can emit. The reconnect
1212
- // lifecycle event bypasses buffering; its framework listener installs the
1213
- // awareness barrier synchronously, then releases control traffic needed
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
- console.error(`MCPL server "${this.id}" reconnect failed: ${(error as Error).message}${withStderr(error)}`);
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
- const stderrTail = stderrTailOf(error);
1234
- this.emit('reconnect-failed', { error: (error as Error).message, ...(stderrTail ? { stderrTail } : {}), attempt });
1235
- this.scheduleReconnect();
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
- return Promise.reject(new McplRequestError(`Cannot send request: connection to "${this.id}" is closed`, 'not-sent'));
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
- `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
1295
- `within ${timeoutMs}ms — the server may be hung. The response ` +
1296
- `outcome is unknown: the request was abandoned locally but was not ` +
1297
- `cancelled, so the tool may still have completed server-side; verify ` +
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
- `Cannot send ${method} to MCPL server "${this.id}": ${err instanceof Error ? err.message : String(err)}`,
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
- const pending = this.pendingRequests.get(response.id);
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
- // describeCause rather than the template's own conversion, as for an
1626
- // initialize error: the server's code and message can be any JSON
1627
- // value, and one with no primitive form, such as {"toString":1}, would
1628
- // throw here, after the request has left pendingRequests and its timer
1629
- // is cleared, so it would never settle. Both are the server's text, a
1630
- // code as much as a message, since a server can send a string, so both
1631
- // are bounded as errors.ts says.
1632
- pending.reject(
1633
- new McplRequestError(
1634
- `MCPL server "${this.id}" returned error for ${pending.method}: [${boundedForLog(describeCause(response.error.code))}] ${boundedForLog(describeCause(response.error.message))}`,
1635
- 'error-response',
1636
- (response.error as { data?: unknown }).data,
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
- if (this.closed) return;
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(id: string | number, code: number, message: string, data?: unknown): void {
1657
- if (this.closed) return;
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
- transport.on('close', (info: TransportCloseInfo) => {
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
- for (const [id, pending] of this.pendingRequests) {
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
- `MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${closeReasonText(info)}) while awaiting ${pending.method} (id=${id})`,
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. A teardown the host asked for
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
  }