@animalabs/agent-framework 0.5.7 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +4 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +15 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +243 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1378 -114
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +64 -2
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +212 -12
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/gate-script-worker.d.ts +2 -0
- package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
- package/dist/src/gate/gate-script-worker.js +69 -0
- package/dist/src/gate/gate-script-worker.js.map +1 -0
- package/dist/src/gate/gate-script.d.ts +44 -0
- package/dist/src/gate/gate-script.d.ts.map +1 -0
- package/dist/src/gate/gate-script.js +164 -0
- package/dist/src/gate/gate-script.js.map +1 -0
- package/dist/src/gate/types.d.ts +18 -3
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +60 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +114 -9
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +15 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +5 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +64 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +247 -210
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +98 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -0
- package/dist/src/mcpl/transport.js +281 -0
- package/dist/src/mcpl/transport.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +59 -3
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +17 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +101 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/prose-segments.d.ts +18 -0
- package/dist/src/prose-segments.d.ts.map +1 -0
- package/dist/src/prose-segments.js +39 -0
- package/dist/src/prose-segments.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
- package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
- package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog.js +106 -0
- package/dist/src/runtime/liveness-watchdog.js.map +1 -0
- package/dist/src/types/agent.d.ts +47 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +58 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +42 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-cache-ttl.test.d.ts +2 -0
- package/dist/test/agent-cache-ttl.test.d.ts.map +1 -0
- package/dist/test/agent-cache-ttl.test.js +25 -0
- package/dist/test/agent-cache-ttl.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +108 -1
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/checkpoint-attribution.test.d.ts +2 -0
- package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
- package/dist/test/checkpoint-attribution.test.js +40 -0
- package/dist/test/checkpoint-attribution.test.js.map +1 -0
- package/dist/test/conversation-fork-injection-scoping.test.d.ts +2 -0
- package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +1 -0
- package/dist/test/conversation-fork-injection-scoping.test.js +184 -0
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.js +137 -0
- package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
- package/dist/test/event-gate-script.test.d.ts +2 -0
- package/dist/test/event-gate-script.test.d.ts.map +1 -0
- package/dist/test/event-gate-script.test.js +95 -0
- package/dist/test/event-gate-script.test.js.map +1 -0
- package/dist/test/event-gate-tags.test.d.ts +2 -0
- package/dist/test/event-gate-tags.test.d.ts.map +1 -0
- package/dist/test/event-gate-tags.test.js +76 -0
- package/dist/test/event-gate-tags.test.js.map +1 -0
- package/dist/test/gather-context-timeout.test.d.ts +2 -0
- package/dist/test/gather-context-timeout.test.d.ts.map +1 -0
- package/dist/test/gather-context-timeout.test.js +120 -0
- package/dist/test/gather-context-timeout.test.js.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.d.ts +2 -0
- package/dist/test/inference-exhaustion-rewind.test.d.ts.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.js +181 -0
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -0
- package/dist/test/inference-failure-observability.test.js +4 -0
- package/dist/test/inference-failure-observability.test.js.map +1 -1
- package/dist/test/liveness-watchdog.test.d.ts +2 -0
- package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
- package/dist/test/liveness-watchdog.test.js +78 -0
- package/dist/test/liveness-watchdog.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.d.ts +2 -0
- package/dist/test/mcpl-reconnect-events.test.d.ts.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.js +119 -0
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.d.ts +2 -0
- package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +181 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -0
- package/dist/test/mcpl-request-timeout.test.d.ts +2 -0
- package/dist/test/mcpl-request-timeout.test.d.ts.map +1 -0
- package/dist/test/mcpl-request-timeout.test.js +123 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -0
- package/dist/test/mcpl-runtime-lifecycle.test.d.ts +7 -0
- package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-runtime-lifecycle.test.js +142 -0
- package/dist/test/mcpl-runtime-lifecycle.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
- package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +155 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -0
- package/dist/test/prose-segments.test.d.ts +2 -0
- package/dist/test/prose-segments.test.d.ts.map +1 -0
- package/dist/test/prose-segments.test.js +52 -0
- package/dist/test/prose-segments.test.js.map +1 -0
- package/dist/test/refusal-rewind.test.d.ts +2 -0
- package/dist/test/refusal-rewind.test.d.ts.map +1 -0
- package/dist/test/refusal-rewind.test.js +108 -0
- package/dist/test/refusal-rewind.test.js.map +1 -0
- package/dist/test/state-scaling-e2e.test.d.ts +22 -0
- package/dist/test/state-scaling-e2e.test.d.ts.map +1 -0
- package/dist/test/state-scaling-e2e.test.js +200 -0
- package/dist/test/state-scaling-e2e.test.js.map +1 -0
- package/dist/test/state-scaling.test.d.ts +12 -0
- package/dist/test/state-scaling.test.d.ts.map +1 -0
- package/dist/test/state-scaling.test.js +243 -0
- package/dist/test/state-scaling.test.js.map +1 -0
- package/dist/test/tool-result-content.test.d.ts +2 -0
- package/dist/test/tool-result-content.test.d.ts.map +1 -0
- package/dist/test/tool-result-content.test.js +37 -0
- package/dist/test/tool-result-content.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.d.ts +19 -0
- package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +175 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/src/agent.ts +16 -0
- package/src/framework.ts +1576 -121
- package/src/gate/event-gate.ts +209 -12
- package/src/gate/gate-script-worker.ts +73 -0
- package/src/gate/gate-script.ts +180 -0
- package/src/gate/types.ts +19 -3
- package/src/index.ts +10 -0
- package/src/mcpl/channel-registry.ts +143 -8
- package/src/mcpl/checkpoint-manager.ts +16 -0
- package/src/mcpl/push-handler.ts +5 -0
- package/src/mcpl/server-connection.ts +262 -227
- package/src/mcpl/transport.ts +307 -0
- package/src/mcpl/types.ts +68 -3
- package/src/module-registry.ts +126 -6
- package/src/prose-segments.ts +42 -0
- package/src/runtime/liveness-watchdog-worker.ts +66 -0
- package/src/runtime/liveness-watchdog.ts +122 -0
- package/src/types/agent.ts +48 -0
- package/src/types/framework.ts +15 -0
- package/src/types/module.ts +65 -0
- package/src/types/trace.ts +51 -0
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* McplServerConnection — manages a single JSON-RPC 2.0 connection to an MCPL server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Connects over a pluggable {@link McplTransport} (a spawned stdio child OR a
|
|
5
|
+
* WebSocket), performs the MCP initialize handshake with MCPL capability
|
|
6
|
+
* negotiation, and provides typed methods for all outbound MCPL messages plus
|
|
7
|
+
* EventEmitter events for inbound messages. The handshake, message routing, and
|
|
8
|
+
* every send path are transport-agnostic — they operate on NDJSON lines, so
|
|
9
|
+
* stdio and WebSocket behave identically above the transport seam.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
10
|
-
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
|
|
11
12
|
import { EventEmitter } from 'node:events';
|
|
12
13
|
|
|
14
|
+
import { openTransport, type McplTransport, type TransportCloseInfo } from './transport.js';
|
|
15
|
+
|
|
13
16
|
import type {
|
|
14
17
|
McplServerConfig,
|
|
15
18
|
McplCapabilities,
|
|
@@ -43,6 +46,13 @@ import { McplMethod } from './types.js';
|
|
|
43
46
|
* Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
|
|
44
47
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
45
48
|
|
|
49
|
+
/** Default per-request timeout in milliseconds (see McplServerConfig.requestTimeoutMs).
|
|
50
|
+
* A live-but-wedged server (accepts requests, never answers, never closes its
|
|
51
|
+
* transport) would otherwise freeze the awaiting agent turn forever — no
|
|
52
|
+
* endTurn, no further wakes, zero errors anywhere. 60s is generous for real
|
|
53
|
+
* tool work while still bounding the hang. */
|
|
54
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
55
|
+
|
|
46
56
|
/** MCP protocol version used in the initialize handshake. */
|
|
47
57
|
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
48
58
|
|
|
@@ -53,6 +63,8 @@ interface PendingRequest {
|
|
|
53
63
|
resolve: (result: unknown) => void;
|
|
54
64
|
reject: (error: Error) => void;
|
|
55
65
|
method: string;
|
|
66
|
+
/** Per-request timeout timer; cleared on response/close/timeout. */
|
|
67
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
/**
|
|
@@ -70,6 +82,9 @@ interface PendingRequest {
|
|
|
70
82
|
* - `'feature-sets-changed'` — Server sent `featureSets/changed`
|
|
71
83
|
* - `'error'` — Connection-level error
|
|
72
84
|
* - `'close'` — Connection closed
|
|
85
|
+
* - `'connect-failed'` — Initial connect failed; background retry scheduled
|
|
86
|
+
* - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
|
|
87
|
+
* - `'reconnect'` — Background reconnect succeeded
|
|
73
88
|
*/
|
|
74
89
|
export class McplServerConnection extends EventEmitter {
|
|
75
90
|
/** Unique server identifier from config. */
|
|
@@ -78,12 +93,23 @@ export class McplServerConnection extends EventEmitter {
|
|
|
78
93
|
/** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
|
|
79
94
|
capabilities: McplCapabilities | null;
|
|
80
95
|
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
/** The active transport (stdio child or WebSocket). Null for a disconnected
|
|
97
|
+
* stub awaiting its first background reconnect. */
|
|
98
|
+
private transport: McplTransport | null;
|
|
99
|
+
|
|
100
|
+
/** Whether the connection currently has a live transport. False for closed
|
|
101
|
+
* connections and disconnected reconnect-stubs awaiting a retry. */
|
|
102
|
+
get isConnected(): boolean {
|
|
103
|
+
return !this.closed && this.transport !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
private nextRequestId = 1;
|
|
84
107
|
private pendingRequests = new Map<string | number, PendingRequest>();
|
|
85
108
|
private closed = false;
|
|
86
109
|
|
|
110
|
+
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
111
|
+
private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
112
|
+
|
|
87
113
|
// Event buffering: events emitted before ready() are queued, not lost
|
|
88
114
|
private readyFlag = false;
|
|
89
115
|
private bufferedEvents: Array<{ event: string; args: unknown[] }> = [];
|
|
@@ -93,10 +119,17 @@ export class McplServerConnection extends EventEmitter {
|
|
|
93
119
|
private hostCapabilities: McplHostCapabilities | null = null;
|
|
94
120
|
private reconnectEnabled = false;
|
|
95
121
|
private reconnectIntervalMs = 5000;
|
|
122
|
+
private reconnectMaxIntervalMs = 300_000;
|
|
96
123
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
124
|
+
/** Consecutive failed connect attempts since the last successful handshake.
|
|
125
|
+
* Drives the exponential backoff and is reported on reconnect events. */
|
|
126
|
+
private reconnectAttempts = 0;
|
|
97
127
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
128
|
+
/** Whether a background reconnect loop will revive this connection after a
|
|
129
|
+
* drop. False once close() has been called (explicit close stops retrying). */
|
|
130
|
+
get willReconnect(): boolean {
|
|
131
|
+
return this.reconnectEnabled;
|
|
132
|
+
}
|
|
100
133
|
|
|
101
134
|
/**
|
|
102
135
|
* Private constructor. Use `McplServerConnection.connect()` instead.
|
|
@@ -104,19 +137,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
104
137
|
private constructor(
|
|
105
138
|
id: string,
|
|
106
139
|
capabilities: McplCapabilities | null,
|
|
107
|
-
|
|
108
|
-
readline: ReadlineInterface,
|
|
140
|
+
transport: McplTransport | null,
|
|
109
141
|
) {
|
|
110
142
|
super();
|
|
111
143
|
this.id = id;
|
|
112
144
|
this.capabilities = capabilities;
|
|
113
|
-
this.
|
|
114
|
-
this.readline = readline;
|
|
145
|
+
this.transport = transport;
|
|
115
146
|
|
|
116
|
-
// Skip setup for disconnected stubs (
|
|
117
|
-
if (
|
|
118
|
-
this.
|
|
119
|
-
this.setupLifecycle();
|
|
147
|
+
// Skip setup for disconnected stubs (transport is null).
|
|
148
|
+
if (transport) {
|
|
149
|
+
this.wireTransport(transport);
|
|
120
150
|
}
|
|
121
151
|
}
|
|
122
152
|
|
|
@@ -155,159 +185,115 @@ export class McplServerConnection extends EventEmitter {
|
|
|
155
185
|
// ==========================================================================
|
|
156
186
|
|
|
157
187
|
/**
|
|
158
|
-
*
|
|
159
|
-
* supplied callback. The callback can be swapped at any time via `rebind`
|
|
160
|
-
* (used during reconnect, when we want already-wired pipes to target a new
|
|
161
|
-
* emitter).
|
|
162
|
-
*/
|
|
163
|
-
private static wireStderrForwarding(
|
|
164
|
-
child: ChildProcess,
|
|
165
|
-
initialLineHandler: (line: string) => void,
|
|
166
|
-
): { rebind: (next: (line: string) => void) => void } {
|
|
167
|
-
let onLine = initialLineHandler;
|
|
168
|
-
let carry = '';
|
|
169
|
-
child.stderr?.on('data', (chunk: Buffer) => {
|
|
170
|
-
const text = carry + chunk.toString('utf8');
|
|
171
|
-
const lines = text.split('\n');
|
|
172
|
-
carry = lines.pop() ?? '';
|
|
173
|
-
for (const line of lines) {
|
|
174
|
-
if (line.length > 0) onLine(line);
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
return {
|
|
178
|
-
rebind(next) {
|
|
179
|
-
onLine = next;
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Spawn a server process, perform the MCP initialize handshake with MCPL
|
|
188
|
+
* Open the transport, perform the MCP initialize handshake with MCPL
|
|
186
189
|
* capability negotiation, and return a ready-to-use connection.
|
|
187
190
|
*
|
|
188
|
-
*
|
|
191
|
+
* Works over stdio (spawned child) or WebSocket, selected by `config` (see
|
|
192
|
+
* {@link openTransport}). Throws if the transport can't be opened, the server
|
|
193
|
+
* closes/errors before the handshake, or the handshake times out.
|
|
189
194
|
*/
|
|
190
195
|
static async connect(
|
|
191
196
|
config: McplServerConfig,
|
|
192
197
|
hostCapabilities: McplHostCapabilities,
|
|
193
198
|
): Promise<McplServerConnection> {
|
|
194
|
-
const
|
|
195
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
196
|
-
env: { ...process.env, ...config.env },
|
|
197
|
-
});
|
|
199
|
+
const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
|
|
198
200
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// drained once we have an instance to emit 'stderr' events on.
|
|
202
|
-
const earlyStderrBuffer: string[] = [];
|
|
203
|
-
const attachStderr = McplServerConnection.wireStderrForwarding(child, (line) =>
|
|
204
|
-
earlyStderrBuffer.push(line),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
// Fail fast if the process exits before the handshake completes
|
|
208
|
-
const earlyExitPromise = new Promise<never>((_resolve, reject) => {
|
|
209
|
-
child.on('error', (err) => {
|
|
210
|
-
reject(new Error(`Failed to spawn MCPL server "${config.id}": ${err.message}`));
|
|
211
|
-
});
|
|
212
|
-
child.on('exit', (code) => {
|
|
213
|
-
reject(new Error(`MCPL server "${config.id}" exited before handshake (code ${code})`));
|
|
214
|
-
});
|
|
215
|
-
});
|
|
201
|
+
const connection = new McplServerConnection(config.id, capabilities, transport);
|
|
202
|
+
connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
216
203
|
|
|
217
|
-
//
|
|
218
|
-
|
|
204
|
+
// Store config for reconnection
|
|
205
|
+
if (config.reconnect) {
|
|
206
|
+
connection.config = config;
|
|
207
|
+
connection.hostCapabilities = hostCapabilities;
|
|
208
|
+
connection.reconnectEnabled = true;
|
|
209
|
+
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
210
|
+
connection.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
211
|
+
}
|
|
219
212
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
// -----------------------------------------------------------------------
|
|
213
|
+
return connection;
|
|
214
|
+
}
|
|
223
215
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
},
|
|
237
|
-
clientInfo: {
|
|
238
|
-
name: 'agent-framework',
|
|
239
|
-
version: '1.0.0',
|
|
240
|
-
},
|
|
241
|
-
},
|
|
242
|
-
};
|
|
243
|
-
child.stdin!.write(JSON.stringify(initRequest) + '\n');
|
|
216
|
+
/**
|
|
217
|
+
* Open a fresh transport and run the MCP/MCPL initialize handshake over it,
|
|
218
|
+
* returning the connected transport plus the negotiated capabilities. Shared
|
|
219
|
+
* by {@link connect} and {@link attemptReconnect} so both transports handshake
|
|
220
|
+
* identically. On any failure the transport is closed so a spawned child /
|
|
221
|
+
* open socket never leaks.
|
|
222
|
+
*/
|
|
223
|
+
private static async handshake(
|
|
224
|
+
config: McplServerConfig,
|
|
225
|
+
hostCapabilities: McplHostCapabilities,
|
|
226
|
+
): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null }> {
|
|
227
|
+
const transport = await openTransport(config);
|
|
244
228
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
229
|
+
try {
|
|
230
|
+
const initId = 0; // use id=0 for the handshake
|
|
231
|
+
const initRequest: JsonRpcRequest = {
|
|
232
|
+
jsonrpc: '2.0',
|
|
233
|
+
method: 'initialize',
|
|
234
|
+
id: initId,
|
|
235
|
+
params: {
|
|
236
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
237
|
+
capabilities: { experimental: { mcpl: hostCapabilities } },
|
|
238
|
+
clientInfo: { name: 'agent-framework', version: '1.0.0' },
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Await the initialize response, racing an early transport close/error and
|
|
243
|
+
// a timeout. All three paths clean up their listeners.
|
|
244
|
+
const capabilities = await new Promise<McplCapabilities | null>((resolve, reject) => {
|
|
245
|
+
const cleanup = () => {
|
|
246
|
+
transport.off('line', onLine);
|
|
247
|
+
transport.off('close', onClose);
|
|
248
|
+
transport.off('error', onError);
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
};
|
|
248
251
|
const onLine = (line: string) => {
|
|
252
|
+
let msg: JsonRpcResponse;
|
|
249
253
|
try {
|
|
250
|
-
|
|
251
|
-
if (msg.id === initId) {
|
|
252
|
-
rl.off('line', onLine);
|
|
253
|
-
if (msg.error) {
|
|
254
|
-
reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
|
|
255
|
-
} else {
|
|
256
|
-
resolve(msg);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
254
|
+
msg = JSON.parse(line) as JsonRpcResponse;
|
|
259
255
|
} catch {
|
|
260
|
-
// Ignore non-JSON lines (e.g. logback output from Java servers)
|
|
256
|
+
return; // Ignore non-JSON lines (e.g. logback output from Java servers)
|
|
257
|
+
}
|
|
258
|
+
if (msg.id !== initId) return;
|
|
259
|
+
cleanup();
|
|
260
|
+
if (msg.error) {
|
|
261
|
+
reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
|
|
262
|
+
return;
|
|
261
263
|
}
|
|
264
|
+
const result = msg.result as Record<string, unknown> | undefined;
|
|
265
|
+
const caps = result?.capabilities as Record<string, unknown> | undefined;
|
|
266
|
+
const experimental = caps?.experimental as Record<string, unknown> | undefined;
|
|
267
|
+
resolve((experimental?.mcpl as McplCapabilities) ?? null);
|
|
268
|
+
};
|
|
269
|
+
const onClose = (info: TransportCloseInfo) => {
|
|
270
|
+
cleanup();
|
|
271
|
+
reject(new Error(`MCPL server "${config.id}" transport closed before handshake completed (${info.reason ?? 'unknown'})`));
|
|
272
|
+
};
|
|
273
|
+
const onError = (err: Error) => {
|
|
274
|
+
cleanup();
|
|
275
|
+
reject(new Error(`MCPL server "${config.id}" transport error during handshake: ${err.message}`));
|
|
262
276
|
};
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
earlyExitPromise,
|
|
266
|
-
new Promise<never>((_resolve, reject) =>
|
|
267
|
-
setTimeout(() => {
|
|
277
|
+
const timer = setTimeout(() => {
|
|
278
|
+
cleanup();
|
|
268
279
|
reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`));
|
|
269
|
-
}, INITIALIZE_TIMEOUT_MS)
|
|
270
|
-
),
|
|
271
|
-
]);
|
|
272
|
-
|
|
273
|
-
// Parse MCPL capabilities from the response
|
|
274
|
-
const result = initResponse.result as Record<string, unknown> | undefined;
|
|
275
|
-
const caps = result?.capabilities as Record<string, unknown> | undefined;
|
|
276
|
-
const experimental = caps?.experimental as Record<string, unknown> | undefined;
|
|
277
|
-
const mcplCaps = (experimental?.mcpl as McplCapabilities) ?? null;
|
|
278
|
-
|
|
279
|
-
// Step 3: Send `initialized` notification (no id)
|
|
280
|
-
const initializedNotification: JsonRpcRequest = {
|
|
281
|
-
jsonrpc: '2.0',
|
|
282
|
-
method: 'notifications/initialized',
|
|
283
|
-
};
|
|
284
|
-
child.stdin!.write(JSON.stringify(initializedNotification) + '\n');
|
|
285
|
-
|
|
286
|
-
// Remove the early-exit listener so normal close handling takes over
|
|
287
|
-
child.removeAllListeners('exit');
|
|
288
|
-
child.removeAllListeners('error');
|
|
280
|
+
}, INITIALIZE_TIMEOUT_MS);
|
|
289
281
|
|
|
290
|
-
|
|
282
|
+
transport.on('line', onLine);
|
|
283
|
+
transport.on('close', onClose);
|
|
284
|
+
transport.on('error', onError);
|
|
285
|
+
transport.writeLine(JSON.stringify(initRequest));
|
|
286
|
+
});
|
|
291
287
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// handle on the instance so reconnect can re-target it later.
|
|
295
|
-
connection.rebindStderr = attachStderr.rebind;
|
|
296
|
-
attachStderr.rebind((line: string) => connection.emit('stderr', { line }));
|
|
297
|
-
for (const line of earlyStderrBuffer) {
|
|
298
|
-
connection.emit('stderr', { line });
|
|
299
|
-
}
|
|
300
|
-
earlyStderrBuffer.length = 0;
|
|
288
|
+
// Send `initialized` notification (no id)
|
|
289
|
+
transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
|
|
301
290
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
291
|
+
return { transport, capabilities };
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Never leak the child / socket if the handshake fails.
|
|
294
|
+
await transport.close().catch(() => { /* best effort */ });
|
|
295
|
+
throw err;
|
|
308
296
|
}
|
|
309
|
-
|
|
310
|
-
return connection;
|
|
311
297
|
}
|
|
312
298
|
|
|
313
299
|
/**
|
|
@@ -329,7 +315,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
329
315
|
|
|
330
316
|
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
331
317
|
console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message);
|
|
332
|
-
|
|
318
|
+
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
319
|
+
// Surface the initial failure. The event is buffered by the emit()
|
|
320
|
+
// override until the host wires listeners and calls ready(), so it is
|
|
321
|
+
// not lost in the window before wireMcplEvents runs.
|
|
322
|
+
stub.emit('connect-failed', { error: (error as Error).message, attempt: 0 });
|
|
323
|
+
return stub;
|
|
333
324
|
}
|
|
334
325
|
}
|
|
335
326
|
|
|
@@ -342,13 +333,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
342
333
|
config: McplServerConfig,
|
|
343
334
|
hostCapabilities: McplHostCapabilities,
|
|
344
335
|
): McplServerConnection {
|
|
345
|
-
//
|
|
346
|
-
const stub = new McplServerConnection(config.id, null, null
|
|
336
|
+
// Null transport — the stub is closed until its first background reconnect.
|
|
337
|
+
const stub = new McplServerConnection(config.id, null, null);
|
|
347
338
|
stub.closed = true;
|
|
339
|
+
stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
348
340
|
stub.config = config;
|
|
349
341
|
stub.hostCapabilities = hostCapabilities;
|
|
350
342
|
stub.reconnectEnabled = true;
|
|
351
343
|
stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
344
|
+
stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
345
|
+
stub.reconnectAttempts = 1; // the initial connect already failed
|
|
352
346
|
|
|
353
347
|
// Schedule background reconnect
|
|
354
348
|
stub.scheduleReconnect();
|
|
@@ -356,18 +350,6 @@ export class McplServerConnection extends EventEmitter {
|
|
|
356
350
|
return stub;
|
|
357
351
|
}
|
|
358
352
|
|
|
359
|
-
/**
|
|
360
|
-
* Extract internals for transfer during reconnection.
|
|
361
|
-
* Strips the source connection — caller takes ownership of the process/readline.
|
|
362
|
-
* @internal Used only by attemptReconnect().
|
|
363
|
-
*/
|
|
364
|
-
extractInternals(): { process: ChildProcess; readline: ReadlineInterface } {
|
|
365
|
-
const result = { process: this.process, readline: this.readline };
|
|
366
|
-
// Prevent the source connection from killing the transferred process
|
|
367
|
-
this.closed = true;
|
|
368
|
-
return result;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
353
|
// ==========================================================================
|
|
372
354
|
// Outbound requests (return Promises)
|
|
373
355
|
// ==========================================================================
|
|
@@ -501,29 +483,16 @@ export class McplServerConnection extends EventEmitter {
|
|
|
501
483
|
|
|
502
484
|
// Reject all pending requests
|
|
503
485
|
for (const [id, pending] of this.pendingRequests) {
|
|
486
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
504
487
|
pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
|
|
505
488
|
}
|
|
506
489
|
this.pendingRequests.clear();
|
|
507
490
|
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// Kill the child process
|
|
514
|
-
if (this.process && !this.process.killed) {
|
|
515
|
-
this.process.kill();
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Wait for process to actually exit
|
|
519
|
-
if (this.process) {
|
|
520
|
-
await new Promise<void>((resolve) => {
|
|
521
|
-
if (this.process.exitCode !== null || this.process.killed) {
|
|
522
|
-
resolve();
|
|
523
|
-
} else {
|
|
524
|
-
this.process.once('exit', () => resolve());
|
|
525
|
-
}
|
|
526
|
-
});
|
|
491
|
+
// Tear down the transport (kills the child / closes the socket). The
|
|
492
|
+
// transport's own 'close' event is short-circuited by `this.closed` in
|
|
493
|
+
// setupLifecycle, so this is the single source of the connection 'close'.
|
|
494
|
+
if (this.transport) {
|
|
495
|
+
await this.transport.close();
|
|
527
496
|
}
|
|
528
497
|
|
|
529
498
|
this.emit('close');
|
|
@@ -531,58 +500,65 @@ export class McplServerConnection extends EventEmitter {
|
|
|
531
500
|
|
|
532
501
|
/**
|
|
533
502
|
* Schedule a background reconnection attempt.
|
|
503
|
+
*
|
|
504
|
+
* Delay grows exponentially with consecutive failures (base
|
|
505
|
+
* `reconnectIntervalMs`, doubling per failure, capped at
|
|
506
|
+
* `reconnectMaxIntervalMs`) with ±25% jitter so a fleet of servers that
|
|
507
|
+
* died together doesn't thundering-herd the same instant. The counter
|
|
508
|
+
* resets on a successful handshake, so a later disconnect starts over at
|
|
509
|
+
* the base interval.
|
|
534
510
|
*/
|
|
535
511
|
private scheduleReconnect(): void {
|
|
536
512
|
if (!this.reconnectEnabled || this.reconnectTimer) return;
|
|
537
513
|
|
|
514
|
+
const exponent = Math.max(0, Math.min(this.reconnectAttempts - 1, 30));
|
|
515
|
+
const uncapped = this.reconnectIntervalMs * 2 ** exponent;
|
|
516
|
+
const capped = Math.min(uncapped, this.reconnectMaxIntervalMs);
|
|
517
|
+
const delay = capped * (0.75 + Math.random() * 0.5);
|
|
518
|
+
|
|
538
519
|
this.reconnectTimer = setTimeout(() => {
|
|
539
520
|
this.reconnectTimer = null;
|
|
540
521
|
this.attemptReconnect();
|
|
541
|
-
},
|
|
522
|
+
}, delay);
|
|
542
523
|
}
|
|
543
524
|
|
|
544
525
|
/**
|
|
545
|
-
* Attempt to reconnect by
|
|
526
|
+
* Attempt to reconnect by opening a fresh transport and performing the
|
|
527
|
+
* handshake, then adopting that transport in place. Unlike the previous
|
|
528
|
+
* approach this builds no throwaway connection instance, so there are no
|
|
529
|
+
* leaked listeners on a dead object.
|
|
546
530
|
*/
|
|
547
531
|
private async attemptReconnect(): Promise<void> {
|
|
548
532
|
if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
|
|
549
533
|
|
|
534
|
+
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
535
|
+
// reports attempt N (reconnectAttempts counts failures so far).
|
|
536
|
+
const attempt = Math.max(1, this.reconnectAttempts);
|
|
537
|
+
|
|
550
538
|
try {
|
|
551
|
-
const
|
|
552
|
-
|
|
539
|
+
const { transport, capabilities } = await McplServerConnection.handshake(
|
|
540
|
+
this.config,
|
|
541
|
+
this.hostCapabilities,
|
|
542
|
+
);
|
|
553
543
|
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
this.
|
|
544
|
+
// Adopt the new transport. Listeners were attached to external consumers
|
|
545
|
+
// on the first wire, so mark ready immediately (re-wire re-binds the
|
|
546
|
+
// transport-level handlers below).
|
|
547
|
+
this.transport = transport;
|
|
548
|
+
this.capabilities = capabilities;
|
|
558
549
|
this.closed = false;
|
|
559
550
|
this.nextRequestId = 1;
|
|
560
551
|
this.pendingRequests.clear();
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
//
|
|
564
|
-
// Note: this follows the same "transfer internals, leak listeners on the
|
|
565
|
-
// dead instance" pattern as the message-routing and lifecycle rewiring
|
|
566
|
-
// below — `fresh`'s own readline 'line' handler and process 'exit'
|
|
567
|
-
// handler stay registered on the transferred child, short-circuited by
|
|
568
|
-
// `fresh.closed = true` set in extractInternals(). Rebinding fresh's
|
|
569
|
-
// stderr listener (rather than removing it) keeps that invariant: if we
|
|
570
|
-
// ever clean up the message-routing duplication, fresh's stderr data
|
|
571
|
-
// listener closure-holds onLine and would need the same teardown.
|
|
572
|
-
if (fresh.rebindStderr) {
|
|
573
|
-
fresh.rebindStderr((line: string) => this.emit('stderr', { line }));
|
|
574
|
-
this.rebindStderr = fresh.rebindStderr;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Re-wire message routing and lifecycle on the new process
|
|
578
|
-
this.setupMessageRouting();
|
|
579
|
-
this.setupLifecycle();
|
|
580
|
-
this.readyFlag = true; // Listeners already attached from initial wire
|
|
552
|
+
this.wireTransport(transport);
|
|
553
|
+
this.readyFlag = true;
|
|
581
554
|
|
|
582
555
|
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
583
|
-
this.
|
|
556
|
+
this.reconnectAttempts = 0;
|
|
557
|
+
this.emit('reconnect', { attempts: attempt });
|
|
584
558
|
} catch (error) {
|
|
585
559
|
console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message);
|
|
560
|
+
this.reconnectAttempts = attempt + 1;
|
|
561
|
+
this.emit('reconnect-failed', { error: (error as Error).message, attempt });
|
|
586
562
|
this.scheduleReconnect();
|
|
587
563
|
}
|
|
588
564
|
}
|
|
@@ -594,6 +570,13 @@ export class McplServerConnection extends EventEmitter {
|
|
|
594
570
|
/**
|
|
595
571
|
* Send a JSON-RPC request and return a promise that resolves with the result
|
|
596
572
|
* or rejects with a JSON-RPC error.
|
|
573
|
+
*
|
|
574
|
+
* Every request carries a timeout (default 60s, configurable via
|
|
575
|
+
* McplServerConfig.requestTimeoutMs, 0 disables): a live-but-stuck server
|
|
576
|
+
* that accepts a request and never answers rejects the pending promise with
|
|
577
|
+
* a descriptive error instead of freezing the caller forever. For tools/call
|
|
578
|
+
* the framework maps the rejection to an isError tool_result, so a hung tool
|
|
579
|
+
* surfaces as a normal tool error and the turn completes.
|
|
597
580
|
*/
|
|
598
581
|
private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
599
582
|
if (this.closed) {
|
|
@@ -609,8 +592,24 @@ export class McplServerConnection extends EventEmitter {
|
|
|
609
592
|
};
|
|
610
593
|
|
|
611
594
|
return new Promise((resolve, reject) => {
|
|
612
|
-
|
|
613
|
-
this.
|
|
595
|
+
const pending: PendingRequest = { resolve, reject, method };
|
|
596
|
+
if (this.requestTimeoutMs > 0) {
|
|
597
|
+
pending.timer = setTimeout(() => {
|
|
598
|
+
this.pendingRequests.delete(id);
|
|
599
|
+
reject(new Error(
|
|
600
|
+
`MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
|
|
601
|
+
`within ${this.requestTimeoutMs}ms — the server may be hung. ` +
|
|
602
|
+
`The request was abandoned; the connection remains open. Note the ` +
|
|
603
|
+
`tool may still have completed server-side (this is only a response ` +
|
|
604
|
+
`timeout, not a cancellation) — verify state before retrying, as a ` +
|
|
605
|
+
`blind retry of a stateful/side-effecting tool may duplicate it.`,
|
|
606
|
+
));
|
|
607
|
+
}, this.requestTimeoutMs);
|
|
608
|
+
// Don't hold the event loop open for the watchdog alone.
|
|
609
|
+
pending.timer.unref?.();
|
|
610
|
+
}
|
|
611
|
+
this.pendingRequests.set(id, pending);
|
|
612
|
+
this.transport!.writeLine(JSON.stringify(request));
|
|
614
613
|
});
|
|
615
614
|
}
|
|
616
615
|
|
|
@@ -627,7 +626,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
627
626
|
method,
|
|
628
627
|
params,
|
|
629
628
|
};
|
|
630
|
-
this.
|
|
629
|
+
this.transport?.writeLine(JSON.stringify(notification));
|
|
631
630
|
}
|
|
632
631
|
|
|
633
632
|
// ==========================================================================
|
|
@@ -653,10 +652,23 @@ export class McplServerConnection extends EventEmitter {
|
|
|
653
652
|
};
|
|
654
653
|
|
|
655
654
|
/**
|
|
656
|
-
*
|
|
655
|
+
* Bind all transport-level handlers (inbound routing, lifecycle, stderr) to a
|
|
656
|
+
* transport. Called from the constructor and again from attemptReconnect when
|
|
657
|
+
* a new transport is adopted.
|
|
658
|
+
*/
|
|
659
|
+
private wireTransport(transport: McplTransport): void {
|
|
660
|
+
this.setupMessageRouting(transport);
|
|
661
|
+
this.setupLifecycle(transport);
|
|
662
|
+
// Surface transport diagnostics (stdio child stderr) as 'stderr' events,
|
|
663
|
+
// preserving the `{ line }` payload existing consumers expect.
|
|
664
|
+
transport.on('stderr', (line: string) => this.emit('stderr', { line }));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Wire up the transport's line stream to route incoming JSON-RPC messages.
|
|
657
669
|
*/
|
|
658
|
-
private setupMessageRouting(): void {
|
|
659
|
-
|
|
670
|
+
private setupMessageRouting(transport: McplTransport): void {
|
|
671
|
+
transport.on('line', (line: string) => {
|
|
660
672
|
let msg: JsonRpcRequest | JsonRpcResponse;
|
|
661
673
|
try {
|
|
662
674
|
msg = JSON.parse(line);
|
|
@@ -699,10 +711,30 @@ export class McplServerConnection extends EventEmitter {
|
|
|
699
711
|
private handleResponse(response: JsonRpcResponse): void {
|
|
700
712
|
const pending = this.pendingRequests.get(response.id);
|
|
701
713
|
if (!pending) {
|
|
714
|
+
// Orphaned response — the request already timed out (or was settled) and
|
|
715
|
+
// was removed from pendingRequests. Stateless late results are harmless to
|
|
716
|
+
// drop, but a late STATEFUL result carries a `state`/`checkpoint` the
|
|
717
|
+
// server has already advanced to: dropping it silently diverges the host's
|
|
718
|
+
// checkpoint tree from the server's, and every subsequent call then sends
|
|
719
|
+
// stale state. We can't safely re-inject it here (the dispatch context is
|
|
720
|
+
// gone), but we surface it so the divergence is greppable instead of
|
|
721
|
+
// invisible.
|
|
722
|
+
const result = response.result;
|
|
723
|
+
if (result && typeof result === 'object') {
|
|
724
|
+
const r = result as { state?: unknown; checkpoint?: unknown };
|
|
725
|
+
if (r.state !== undefined || r.checkpoint !== undefined) {
|
|
726
|
+
this.emit('orphaned-response', {
|
|
727
|
+
id: response.id,
|
|
728
|
+
hadState: r.state !== undefined,
|
|
729
|
+
hadCheckpoint: r.checkpoint !== undefined,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
702
733
|
return; // Orphaned response — ignore
|
|
703
734
|
}
|
|
704
735
|
|
|
705
736
|
this.pendingRequests.delete(response.id);
|
|
737
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
706
738
|
|
|
707
739
|
if (response.error) {
|
|
708
740
|
pending.reject(
|
|
@@ -719,7 +751,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
719
751
|
private sendResponse(id: string | number, result: unknown): void {
|
|
720
752
|
if (this.closed) return;
|
|
721
753
|
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
|
|
722
|
-
this.
|
|
754
|
+
this.transport?.writeLine(JSON.stringify(response));
|
|
723
755
|
}
|
|
724
756
|
|
|
725
757
|
/**
|
|
@@ -732,7 +764,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
732
764
|
id,
|
|
733
765
|
error: { code, message, data },
|
|
734
766
|
};
|
|
735
|
-
this.
|
|
767
|
+
this.transport?.writeLine(JSON.stringify(response));
|
|
736
768
|
}
|
|
737
769
|
|
|
738
770
|
// ==========================================================================
|
|
@@ -740,30 +772,33 @@ export class McplServerConnection extends EventEmitter {
|
|
|
740
772
|
// ==========================================================================
|
|
741
773
|
|
|
742
774
|
/**
|
|
743
|
-
* Set up
|
|
775
|
+
* Set up transport error/close handlers. An unexpected transport close
|
|
776
|
+
* (child exit or WebSocket drop) rejects in-flight requests, emits 'close',
|
|
777
|
+
* and — when enabled — schedules a background reconnect.
|
|
744
778
|
*/
|
|
745
|
-
private setupLifecycle(): void {
|
|
746
|
-
|
|
747
|
-
this.emit('error', new Error(`MCPL server "${this.id}"
|
|
779
|
+
private setupLifecycle(transport: McplTransport): void {
|
|
780
|
+
transport.on('error', (err: Error) => {
|
|
781
|
+
this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
|
|
748
782
|
});
|
|
749
783
|
|
|
750
|
-
|
|
784
|
+
transport.on('close', (info: TransportCloseInfo) => {
|
|
751
785
|
if (!this.closed) {
|
|
752
786
|
this.closed = true;
|
|
753
787
|
|
|
754
788
|
// Reject all pending requests
|
|
755
789
|
for (const [id, pending] of this.pendingRequests) {
|
|
790
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
756
791
|
pending.reject(
|
|
757
792
|
new Error(
|
|
758
|
-
`MCPL server "${this.id}"
|
|
793
|
+
`MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`,
|
|
759
794
|
),
|
|
760
795
|
);
|
|
761
796
|
}
|
|
762
797
|
this.pendingRequests.clear();
|
|
763
798
|
|
|
764
|
-
this.emit('close', code, signal);
|
|
799
|
+
this.emit('close', info.code ?? null, info.signal ?? null);
|
|
765
800
|
|
|
766
|
-
// Schedule reconnect if enabled (unexpected
|
|
801
|
+
// Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
|
|
767
802
|
if (this.reconnectEnabled) {
|
|
768
803
|
this.scheduleReconnect();
|
|
769
804
|
}
|