@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,17 +1,25 @@
|
|
|
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
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import { createInterface } from 'node:readline';
|
|
10
11
|
import { EventEmitter } from 'node:events';
|
|
12
|
+
import { openTransport } from './transport.js';
|
|
11
13
|
import { McplMethod } from './types.js';
|
|
12
14
|
/** Timeout for the initialize handshake in milliseconds.
|
|
13
15
|
* Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
|
|
14
16
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
17
|
+
/** Default per-request timeout in milliseconds (see McplServerConfig.requestTimeoutMs).
|
|
18
|
+
* A live-but-wedged server (accepts requests, never answers, never closes its
|
|
19
|
+
* transport) would otherwise freeze the awaiting agent turn forever — no
|
|
20
|
+
* endTurn, no further wakes, zero errors anywhere. 60s is generous for real
|
|
21
|
+
* tool work while still bounding the hang. */
|
|
22
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
15
23
|
/** MCP protocol version used in the initialize handshake. */
|
|
16
24
|
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
17
25
|
/**
|
|
@@ -29,17 +37,28 @@ const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
|
29
37
|
* - `'feature-sets-changed'` — Server sent `featureSets/changed`
|
|
30
38
|
* - `'error'` — Connection-level error
|
|
31
39
|
* - `'close'` — Connection closed
|
|
40
|
+
* - `'connect-failed'` — Initial connect failed; background retry scheduled
|
|
41
|
+
* - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
|
|
42
|
+
* - `'reconnect'` — Background reconnect succeeded
|
|
32
43
|
*/
|
|
33
44
|
export class McplServerConnection extends EventEmitter {
|
|
34
45
|
/** Unique server identifier from config. */
|
|
35
46
|
id;
|
|
36
47
|
/** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
|
|
37
48
|
capabilities;
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
/** The active transport (stdio child or WebSocket). Null for a disconnected
|
|
50
|
+
* stub awaiting its first background reconnect. */
|
|
51
|
+
transport;
|
|
52
|
+
/** Whether the connection currently has a live transport. False for closed
|
|
53
|
+
* connections and disconnected reconnect-stubs awaiting a retry. */
|
|
54
|
+
get isConnected() {
|
|
55
|
+
return !this.closed && this.transport !== null;
|
|
56
|
+
}
|
|
40
57
|
nextRequestId = 1;
|
|
41
58
|
pendingRequests = new Map();
|
|
42
59
|
closed = false;
|
|
60
|
+
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
61
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
43
62
|
// Event buffering: events emitted before ready() are queued, not lost
|
|
44
63
|
readyFlag = false;
|
|
45
64
|
bufferedEvents = [];
|
|
@@ -48,22 +67,27 @@ export class McplServerConnection extends EventEmitter {
|
|
|
48
67
|
hostCapabilities = null;
|
|
49
68
|
reconnectEnabled = false;
|
|
50
69
|
reconnectIntervalMs = 5000;
|
|
70
|
+
reconnectMaxIntervalMs = 300_000;
|
|
51
71
|
reconnectTimer = null;
|
|
52
|
-
/**
|
|
53
|
-
|
|
72
|
+
/** Consecutive failed connect attempts since the last successful handshake.
|
|
73
|
+
* Drives the exponential backoff and is reported on reconnect events. */
|
|
74
|
+
reconnectAttempts = 0;
|
|
75
|
+
/** Whether a background reconnect loop will revive this connection after a
|
|
76
|
+
* drop. False once close() has been called (explicit close stops retrying). */
|
|
77
|
+
get willReconnect() {
|
|
78
|
+
return this.reconnectEnabled;
|
|
79
|
+
}
|
|
54
80
|
/**
|
|
55
81
|
* Private constructor. Use `McplServerConnection.connect()` instead.
|
|
56
82
|
*/
|
|
57
|
-
constructor(id, capabilities,
|
|
83
|
+
constructor(id, capabilities, transport) {
|
|
58
84
|
super();
|
|
59
85
|
this.id = id;
|
|
60
86
|
this.capabilities = capabilities;
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.setupMessageRouting();
|
|
66
|
-
this.setupLifecycle();
|
|
87
|
+
this.transport = transport;
|
|
88
|
+
// Skip setup for disconnected stubs (transport is null).
|
|
89
|
+
if (transport) {
|
|
90
|
+
this.wireTransport(transport);
|
|
67
91
|
}
|
|
68
92
|
}
|
|
69
93
|
// ==========================================================================
|
|
@@ -97,138 +121,103 @@ export class McplServerConnection extends EventEmitter {
|
|
|
97
121
|
// Static factory
|
|
98
122
|
// ==========================================================================
|
|
99
123
|
/**
|
|
100
|
-
*
|
|
101
|
-
* supplied callback. The callback can be swapped at any time via `rebind`
|
|
102
|
-
* (used during reconnect, when we want already-wired pipes to target a new
|
|
103
|
-
* emitter).
|
|
104
|
-
*/
|
|
105
|
-
static wireStderrForwarding(child, initialLineHandler) {
|
|
106
|
-
let onLine = initialLineHandler;
|
|
107
|
-
let carry = '';
|
|
108
|
-
child.stderr?.on('data', (chunk) => {
|
|
109
|
-
const text = carry + chunk.toString('utf8');
|
|
110
|
-
const lines = text.split('\n');
|
|
111
|
-
carry = lines.pop() ?? '';
|
|
112
|
-
for (const line of lines) {
|
|
113
|
-
if (line.length > 0)
|
|
114
|
-
onLine(line);
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
return {
|
|
118
|
-
rebind(next) {
|
|
119
|
-
onLine = next;
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Spawn a server process, perform the MCP initialize handshake with MCPL
|
|
124
|
+
* Open the transport, perform the MCP initialize handshake with MCPL
|
|
125
125
|
* capability negotiation, and return a ready-to-use connection.
|
|
126
126
|
*
|
|
127
|
-
*
|
|
127
|
+
* Works over stdio (spawned child) or WebSocket, selected by `config` (see
|
|
128
|
+
* {@link openTransport}). Throws if the transport can't be opened, the server
|
|
129
|
+
* closes/errors before the handshake, or the handshake times out.
|
|
128
130
|
*/
|
|
129
131
|
static async connect(config, hostCapabilities) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
experimental: {
|
|
163
|
-
mcpl: hostCapabilities,
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
clientInfo: {
|
|
167
|
-
name: 'agent-framework',
|
|
168
|
-
version: '1.0.0',
|
|
132
|
+
const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
|
|
133
|
+
const connection = new McplServerConnection(config.id, capabilities, transport);
|
|
134
|
+
connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
135
|
+
// Store config for reconnection
|
|
136
|
+
if (config.reconnect) {
|
|
137
|
+
connection.config = config;
|
|
138
|
+
connection.hostCapabilities = hostCapabilities;
|
|
139
|
+
connection.reconnectEnabled = true;
|
|
140
|
+
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
141
|
+
connection.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
142
|
+
}
|
|
143
|
+
return connection;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Open a fresh transport and run the MCP/MCPL initialize handshake over it,
|
|
147
|
+
* returning the connected transport plus the negotiated capabilities. Shared
|
|
148
|
+
* by {@link connect} and {@link attemptReconnect} so both transports handshake
|
|
149
|
+
* identically. On any failure the transport is closed so a spawned child /
|
|
150
|
+
* open socket never leaks.
|
|
151
|
+
*/
|
|
152
|
+
static async handshake(config, hostCapabilities) {
|
|
153
|
+
const transport = await openTransport(config);
|
|
154
|
+
try {
|
|
155
|
+
const initId = 0; // use id=0 for the handshake
|
|
156
|
+
const initRequest = {
|
|
157
|
+
jsonrpc: '2.0',
|
|
158
|
+
method: 'initialize',
|
|
159
|
+
id: initId,
|
|
160
|
+
params: {
|
|
161
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
162
|
+
capabilities: { experimental: { mcpl: hostCapabilities } },
|
|
163
|
+
clientInfo: { name: 'agent-framework', version: '1.0.0' },
|
|
169
164
|
},
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
165
|
+
};
|
|
166
|
+
// Await the initialize response, racing an early transport close/error and
|
|
167
|
+
// a timeout. All three paths clean up their listeners.
|
|
168
|
+
const capabilities = await new Promise((resolve, reject) => {
|
|
169
|
+
const cleanup = () => {
|
|
170
|
+
transport.off('line', onLine);
|
|
171
|
+
transport.off('close', onClose);
|
|
172
|
+
transport.off('error', onError);
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
};
|
|
176
175
|
const onLine = (line) => {
|
|
176
|
+
let msg;
|
|
177
177
|
try {
|
|
178
|
-
|
|
179
|
-
if (msg.id === initId) {
|
|
180
|
-
rl.off('line', onLine);
|
|
181
|
-
if (msg.error) {
|
|
182
|
-
reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
resolve(msg);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
178
|
+
msg = JSON.parse(line);
|
|
188
179
|
}
|
|
189
180
|
catch {
|
|
190
|
-
// Ignore non-JSON lines (e.g. logback output from Java servers)
|
|
181
|
+
return; // Ignore non-JSON lines (e.g. logback output from Java servers)
|
|
191
182
|
}
|
|
183
|
+
if (msg.id !== initId)
|
|
184
|
+
return;
|
|
185
|
+
cleanup();
|
|
186
|
+
if (msg.error) {
|
|
187
|
+
reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const result = msg.result;
|
|
191
|
+
const caps = result?.capabilities;
|
|
192
|
+
const experimental = caps?.experimental;
|
|
193
|
+
resolve(experimental?.mcpl ?? null);
|
|
192
194
|
};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
child.removeAllListeners('error');
|
|
214
|
-
const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
|
|
215
|
-
// Swap the stderr handler to emit events on the instance, then drain
|
|
216
|
-
// anything that arrived during the handshake window. Keep the rebind
|
|
217
|
-
// handle on the instance so reconnect can re-target it later.
|
|
218
|
-
connection.rebindStderr = attachStderr.rebind;
|
|
219
|
-
attachStderr.rebind((line) => connection.emit('stderr', { line }));
|
|
220
|
-
for (const line of earlyStderrBuffer) {
|
|
221
|
-
connection.emit('stderr', { line });
|
|
195
|
+
const onClose = (info) => {
|
|
196
|
+
cleanup();
|
|
197
|
+
reject(new Error(`MCPL server "${config.id}" transport closed before handshake completed (${info.reason ?? 'unknown'})`));
|
|
198
|
+
};
|
|
199
|
+
const onError = (err) => {
|
|
200
|
+
cleanup();
|
|
201
|
+
reject(new Error(`MCPL server "${config.id}" transport error during handshake: ${err.message}`));
|
|
202
|
+
};
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
cleanup();
|
|
205
|
+
reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`));
|
|
206
|
+
}, INITIALIZE_TIMEOUT_MS);
|
|
207
|
+
transport.on('line', onLine);
|
|
208
|
+
transport.on('close', onClose);
|
|
209
|
+
transport.on('error', onError);
|
|
210
|
+
transport.writeLine(JSON.stringify(initRequest));
|
|
211
|
+
});
|
|
212
|
+
// Send `initialized` notification (no id)
|
|
213
|
+
transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
|
|
214
|
+
return { transport, capabilities };
|
|
222
215
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
connection.hostCapabilities = hostCapabilities;
|
|
228
|
-
connection.reconnectEnabled = true;
|
|
229
|
-
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
216
|
+
catch (err) {
|
|
217
|
+
// Never leak the child / socket if the handshake fails.
|
|
218
|
+
await transport.close().catch(() => { });
|
|
219
|
+
throw err;
|
|
230
220
|
}
|
|
231
|
-
return connection;
|
|
232
221
|
}
|
|
233
222
|
/**
|
|
234
223
|
* Connect with reconnect support.
|
|
@@ -246,7 +235,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
246
235
|
}
|
|
247
236
|
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
248
237
|
console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, error.message);
|
|
249
|
-
|
|
238
|
+
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
239
|
+
// Surface the initial failure. The event is buffered by the emit()
|
|
240
|
+
// override until the host wires listeners and calls ready(), so it is
|
|
241
|
+
// not lost in the window before wireMcplEvents runs.
|
|
242
|
+
stub.emit('connect-failed', { error: error.message, attempt: 0 });
|
|
243
|
+
return stub;
|
|
250
244
|
}
|
|
251
245
|
}
|
|
252
246
|
/**
|
|
@@ -255,28 +249,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
255
249
|
* @internal
|
|
256
250
|
*/
|
|
257
251
|
static createDisconnectedStub(config, hostCapabilities) {
|
|
258
|
-
//
|
|
259
|
-
const stub = new McplServerConnection(config.id, null, null
|
|
252
|
+
// Null transport — the stub is closed until its first background reconnect.
|
|
253
|
+
const stub = new McplServerConnection(config.id, null, null);
|
|
260
254
|
stub.closed = true;
|
|
255
|
+
stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
261
256
|
stub.config = config;
|
|
262
257
|
stub.hostCapabilities = hostCapabilities;
|
|
263
258
|
stub.reconnectEnabled = true;
|
|
264
259
|
stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
260
|
+
stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
261
|
+
stub.reconnectAttempts = 1; // the initial connect already failed
|
|
265
262
|
// Schedule background reconnect
|
|
266
263
|
stub.scheduleReconnect();
|
|
267
264
|
return stub;
|
|
268
265
|
}
|
|
269
|
-
/**
|
|
270
|
-
* Extract internals for transfer during reconnection.
|
|
271
|
-
* Strips the source connection — caller takes ownership of the process/readline.
|
|
272
|
-
* @internal Used only by attemptReconnect().
|
|
273
|
-
*/
|
|
274
|
-
extractInternals() {
|
|
275
|
-
const result = { process: this.process, readline: this.readline };
|
|
276
|
-
// Prevent the source connection from killing the transferred process
|
|
277
|
-
this.closed = true;
|
|
278
|
-
return result;
|
|
279
|
-
}
|
|
280
266
|
// ==========================================================================
|
|
281
267
|
// Outbound requests (return Promises)
|
|
282
268
|
// ==========================================================================
|
|
@@ -386,80 +372,73 @@ export class McplServerConnection extends EventEmitter {
|
|
|
386
372
|
this.closed = true;
|
|
387
373
|
// Reject all pending requests
|
|
388
374
|
for (const [id, pending] of this.pendingRequests) {
|
|
375
|
+
if (pending.timer)
|
|
376
|
+
clearTimeout(pending.timer);
|
|
389
377
|
pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
|
|
390
378
|
}
|
|
391
379
|
this.pendingRequests.clear();
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (this.process && !this.process.killed) {
|
|
398
|
-
this.process.kill();
|
|
399
|
-
}
|
|
400
|
-
// Wait for process to actually exit
|
|
401
|
-
if (this.process) {
|
|
402
|
-
await new Promise((resolve) => {
|
|
403
|
-
if (this.process.exitCode !== null || this.process.killed) {
|
|
404
|
-
resolve();
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
this.process.once('exit', () => resolve());
|
|
408
|
-
}
|
|
409
|
-
});
|
|
380
|
+
// Tear down the transport (kills the child / closes the socket). The
|
|
381
|
+
// transport's own 'close' event is short-circuited by `this.closed` in
|
|
382
|
+
// setupLifecycle, so this is the single source of the connection 'close'.
|
|
383
|
+
if (this.transport) {
|
|
384
|
+
await this.transport.close();
|
|
410
385
|
}
|
|
411
386
|
this.emit('close');
|
|
412
387
|
}
|
|
413
388
|
/**
|
|
414
389
|
* Schedule a background reconnection attempt.
|
|
390
|
+
*
|
|
391
|
+
* Delay grows exponentially with consecutive failures (base
|
|
392
|
+
* `reconnectIntervalMs`, doubling per failure, capped at
|
|
393
|
+
* `reconnectMaxIntervalMs`) with ±25% jitter so a fleet of servers that
|
|
394
|
+
* died together doesn't thundering-herd the same instant. The counter
|
|
395
|
+
* resets on a successful handshake, so a later disconnect starts over at
|
|
396
|
+
* the base interval.
|
|
415
397
|
*/
|
|
416
398
|
scheduleReconnect() {
|
|
417
399
|
if (!this.reconnectEnabled || this.reconnectTimer)
|
|
418
400
|
return;
|
|
401
|
+
const exponent = Math.max(0, Math.min(this.reconnectAttempts - 1, 30));
|
|
402
|
+
const uncapped = this.reconnectIntervalMs * 2 ** exponent;
|
|
403
|
+
const capped = Math.min(uncapped, this.reconnectMaxIntervalMs);
|
|
404
|
+
const delay = capped * (0.75 + Math.random() * 0.5);
|
|
419
405
|
this.reconnectTimer = setTimeout(() => {
|
|
420
406
|
this.reconnectTimer = null;
|
|
421
407
|
this.attemptReconnect();
|
|
422
|
-
},
|
|
408
|
+
}, delay);
|
|
423
409
|
}
|
|
424
410
|
/**
|
|
425
|
-
* Attempt to reconnect by
|
|
411
|
+
* Attempt to reconnect by opening a fresh transport and performing the
|
|
412
|
+
* handshake, then adopting that transport in place. Unlike the previous
|
|
413
|
+
* approach this builds no throwaway connection instance, so there are no
|
|
414
|
+
* leaked listeners on a dead object.
|
|
426
415
|
*/
|
|
427
416
|
async attemptReconnect() {
|
|
428
417
|
if (!this.reconnectEnabled || !this.config || !this.hostCapabilities)
|
|
429
418
|
return;
|
|
419
|
+
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
420
|
+
// reports attempt N (reconnectAttempts counts failures so far).
|
|
421
|
+
const attempt = Math.max(1, this.reconnectAttempts);
|
|
430
422
|
try {
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
this.
|
|
436
|
-
this.capabilities =
|
|
423
|
+
const { transport, capabilities } = await McplServerConnection.handshake(this.config, this.hostCapabilities);
|
|
424
|
+
// Adopt the new transport. Listeners were attached to external consumers
|
|
425
|
+
// on the first wire, so mark ready immediately (re-wire re-binds the
|
|
426
|
+
// transport-level handlers below).
|
|
427
|
+
this.transport = transport;
|
|
428
|
+
this.capabilities = capabilities;
|
|
437
429
|
this.closed = false;
|
|
438
430
|
this.nextRequestId = 1;
|
|
439
431
|
this.pendingRequests.clear();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
// Note: this follows the same "transfer internals, leak listeners on the
|
|
443
|
-
// dead instance" pattern as the message-routing and lifecycle rewiring
|
|
444
|
-
// below — `fresh`'s own readline 'line' handler and process 'exit'
|
|
445
|
-
// handler stay registered on the transferred child, short-circuited by
|
|
446
|
-
// `fresh.closed = true` set in extractInternals(). Rebinding fresh's
|
|
447
|
-
// stderr listener (rather than removing it) keeps that invariant: if we
|
|
448
|
-
// ever clean up the message-routing duplication, fresh's stderr data
|
|
449
|
-
// listener closure-holds onLine and would need the same teardown.
|
|
450
|
-
if (fresh.rebindStderr) {
|
|
451
|
-
fresh.rebindStderr((line) => this.emit('stderr', { line }));
|
|
452
|
-
this.rebindStderr = fresh.rebindStderr;
|
|
453
|
-
}
|
|
454
|
-
// Re-wire message routing and lifecycle on the new process
|
|
455
|
-
this.setupMessageRouting();
|
|
456
|
-
this.setupLifecycle();
|
|
457
|
-
this.readyFlag = true; // Listeners already attached from initial wire
|
|
432
|
+
this.wireTransport(transport);
|
|
433
|
+
this.readyFlag = true;
|
|
458
434
|
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
459
|
-
this.
|
|
435
|
+
this.reconnectAttempts = 0;
|
|
436
|
+
this.emit('reconnect', { attempts: attempt });
|
|
460
437
|
}
|
|
461
438
|
catch (error) {
|
|
462
439
|
console.error(`MCPL server "${this.id}" reconnect failed:`, error.message);
|
|
440
|
+
this.reconnectAttempts = attempt + 1;
|
|
441
|
+
this.emit('reconnect-failed', { error: error.message, attempt });
|
|
463
442
|
this.scheduleReconnect();
|
|
464
443
|
}
|
|
465
444
|
}
|
|
@@ -469,6 +448,13 @@ export class McplServerConnection extends EventEmitter {
|
|
|
469
448
|
/**
|
|
470
449
|
* Send a JSON-RPC request and return a promise that resolves with the result
|
|
471
450
|
* or rejects with a JSON-RPC error.
|
|
451
|
+
*
|
|
452
|
+
* Every request carries a timeout (default 60s, configurable via
|
|
453
|
+
* McplServerConfig.requestTimeoutMs, 0 disables): a live-but-stuck server
|
|
454
|
+
* that accepts a request and never answers rejects the pending promise with
|
|
455
|
+
* a descriptive error instead of freezing the caller forever. For tools/call
|
|
456
|
+
* the framework maps the rejection to an isError tool_result, so a hung tool
|
|
457
|
+
* surfaces as a normal tool error and the turn completes.
|
|
472
458
|
*/
|
|
473
459
|
sendRequest(method, params) {
|
|
474
460
|
if (this.closed) {
|
|
@@ -482,8 +468,22 @@ export class McplServerConnection extends EventEmitter {
|
|
|
482
468
|
params,
|
|
483
469
|
};
|
|
484
470
|
return new Promise((resolve, reject) => {
|
|
485
|
-
|
|
486
|
-
this.
|
|
471
|
+
const pending = { resolve, reject, method };
|
|
472
|
+
if (this.requestTimeoutMs > 0) {
|
|
473
|
+
pending.timer = setTimeout(() => {
|
|
474
|
+
this.pendingRequests.delete(id);
|
|
475
|
+
reject(new Error(`MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
|
|
476
|
+
`within ${this.requestTimeoutMs}ms — the server may be hung. ` +
|
|
477
|
+
`The request was abandoned; the connection remains open. Note the ` +
|
|
478
|
+
`tool may still have completed server-side (this is only a response ` +
|
|
479
|
+
`timeout, not a cancellation) — verify state before retrying, as a ` +
|
|
480
|
+
`blind retry of a stateful/side-effecting tool may duplicate it.`));
|
|
481
|
+
}, this.requestTimeoutMs);
|
|
482
|
+
// Don't hold the event loop open for the watchdog alone.
|
|
483
|
+
pending.timer.unref?.();
|
|
484
|
+
}
|
|
485
|
+
this.pendingRequests.set(id, pending);
|
|
486
|
+
this.transport.writeLine(JSON.stringify(request));
|
|
487
487
|
});
|
|
488
488
|
}
|
|
489
489
|
/**
|
|
@@ -498,7 +498,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
498
498
|
method,
|
|
499
499
|
params,
|
|
500
500
|
};
|
|
501
|
-
this.
|
|
501
|
+
this.transport?.writeLine(JSON.stringify(notification));
|
|
502
502
|
}
|
|
503
503
|
// ==========================================================================
|
|
504
504
|
// Private: inbound message routing
|
|
@@ -521,10 +521,22 @@ export class McplServerConnection extends EventEmitter {
|
|
|
521
521
|
'host/command': 'host-command',
|
|
522
522
|
};
|
|
523
523
|
/**
|
|
524
|
-
*
|
|
524
|
+
* Bind all transport-level handlers (inbound routing, lifecycle, stderr) to a
|
|
525
|
+
* transport. Called from the constructor and again from attemptReconnect when
|
|
526
|
+
* a new transport is adopted.
|
|
527
|
+
*/
|
|
528
|
+
wireTransport(transport) {
|
|
529
|
+
this.setupMessageRouting(transport);
|
|
530
|
+
this.setupLifecycle(transport);
|
|
531
|
+
// Surface transport diagnostics (stdio child stderr) as 'stderr' events,
|
|
532
|
+
// preserving the `{ line }` payload existing consumers expect.
|
|
533
|
+
transport.on('stderr', (line) => this.emit('stderr', { line }));
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Wire up the transport's line stream to route incoming JSON-RPC messages.
|
|
525
537
|
*/
|
|
526
|
-
setupMessageRouting() {
|
|
527
|
-
|
|
538
|
+
setupMessageRouting(transport) {
|
|
539
|
+
transport.on('line', (line) => {
|
|
528
540
|
let msg;
|
|
529
541
|
try {
|
|
530
542
|
msg = JSON.parse(line);
|
|
@@ -564,9 +576,30 @@ export class McplServerConnection extends EventEmitter {
|
|
|
564
576
|
handleResponse(response) {
|
|
565
577
|
const pending = this.pendingRequests.get(response.id);
|
|
566
578
|
if (!pending) {
|
|
579
|
+
// Orphaned response — the request already timed out (or was settled) and
|
|
580
|
+
// was removed from pendingRequests. Stateless late results are harmless to
|
|
581
|
+
// drop, but a late STATEFUL result carries a `state`/`checkpoint` the
|
|
582
|
+
// server has already advanced to: dropping it silently diverges the host's
|
|
583
|
+
// checkpoint tree from the server's, and every subsequent call then sends
|
|
584
|
+
// stale state. We can't safely re-inject it here (the dispatch context is
|
|
585
|
+
// gone), but we surface it so the divergence is greppable instead of
|
|
586
|
+
// invisible.
|
|
587
|
+
const result = response.result;
|
|
588
|
+
if (result && typeof result === 'object') {
|
|
589
|
+
const r = result;
|
|
590
|
+
if (r.state !== undefined || r.checkpoint !== undefined) {
|
|
591
|
+
this.emit('orphaned-response', {
|
|
592
|
+
id: response.id,
|
|
593
|
+
hadState: r.state !== undefined,
|
|
594
|
+
hadCheckpoint: r.checkpoint !== undefined,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
567
598
|
return; // Orphaned response — ignore
|
|
568
599
|
}
|
|
569
600
|
this.pendingRequests.delete(response.id);
|
|
601
|
+
if (pending.timer)
|
|
602
|
+
clearTimeout(pending.timer);
|
|
570
603
|
if (response.error) {
|
|
571
604
|
pending.reject(new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`));
|
|
572
605
|
}
|
|
@@ -581,7 +614,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
581
614
|
if (this.closed)
|
|
582
615
|
return;
|
|
583
616
|
const response = { jsonrpc: '2.0', id, result };
|
|
584
|
-
this.
|
|
617
|
+
this.transport?.writeLine(JSON.stringify(response));
|
|
585
618
|
}
|
|
586
619
|
/**
|
|
587
620
|
* Send a JSON-RPC error response back to the server.
|
|
@@ -594,28 +627,32 @@ export class McplServerConnection extends EventEmitter {
|
|
|
594
627
|
id,
|
|
595
628
|
error: { code, message, data },
|
|
596
629
|
};
|
|
597
|
-
this.
|
|
630
|
+
this.transport?.writeLine(JSON.stringify(response));
|
|
598
631
|
}
|
|
599
632
|
// ==========================================================================
|
|
600
633
|
// Private: lifecycle
|
|
601
634
|
// ==========================================================================
|
|
602
635
|
/**
|
|
603
|
-
* Set up
|
|
636
|
+
* Set up transport error/close handlers. An unexpected transport close
|
|
637
|
+
* (child exit or WebSocket drop) rejects in-flight requests, emits 'close',
|
|
638
|
+
* and — when enabled — schedules a background reconnect.
|
|
604
639
|
*/
|
|
605
|
-
setupLifecycle() {
|
|
606
|
-
|
|
607
|
-
this.emit('error', new Error(`MCPL server "${this.id}"
|
|
640
|
+
setupLifecycle(transport) {
|
|
641
|
+
transport.on('error', (err) => {
|
|
642
|
+
this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
|
|
608
643
|
});
|
|
609
|
-
|
|
644
|
+
transport.on('close', (info) => {
|
|
610
645
|
if (!this.closed) {
|
|
611
646
|
this.closed = true;
|
|
612
647
|
// Reject all pending requests
|
|
613
648
|
for (const [id, pending] of this.pendingRequests) {
|
|
614
|
-
|
|
649
|
+
if (pending.timer)
|
|
650
|
+
clearTimeout(pending.timer);
|
|
651
|
+
pending.reject(new Error(`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})`));
|
|
615
652
|
}
|
|
616
653
|
this.pendingRequests.clear();
|
|
617
|
-
this.emit('close', code, signal);
|
|
618
|
-
// Schedule reconnect if enabled (unexpected
|
|
654
|
+
this.emit('close', info.code ?? null, info.signal ?? null);
|
|
655
|
+
// Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
|
|
619
656
|
if (this.reconnectEnabled) {
|
|
620
657
|
this.scheduleReconnect();
|
|
621
658
|
}
|