@animalabs/agent-framework 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/src/agent.d.ts +130 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +406 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/api/mcp-server.d.ts +37 -0
  8. package/dist/src/api/mcp-server.d.ts.map +1 -0
  9. package/dist/src/api/mcp-server.js +598 -0
  10. package/dist/src/api/mcp-server.js.map +1 -0
  11. package/dist/src/api/server.d.ts +83 -0
  12. package/dist/src/api/server.d.ts.map +1 -0
  13. package/dist/src/api/server.js +785 -0
  14. package/dist/src/api/server.js.map +1 -0
  15. package/dist/src/api/types.d.ts +395 -0
  16. package/dist/src/api/types.d.ts.map +1 -0
  17. package/dist/src/api/types.js +5 -0
  18. package/dist/src/api/types.js.map +1 -0
  19. package/dist/src/framework.d.ts +309 -0
  20. package/dist/src/framework.d.ts.map +1 -0
  21. package/dist/src/framework.js +2309 -0
  22. package/dist/src/framework.js.map +1 -0
  23. package/dist/src/gate/event-gate.d.ts +75 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -0
  25. package/dist/src/gate/event-gate.js +503 -0
  26. package/dist/src/gate/event-gate.js.map +1 -0
  27. package/dist/src/gate/index.d.ts +3 -0
  28. package/dist/src/gate/index.d.ts.map +1 -0
  29. package/dist/src/gate/index.js +2 -0
  30. package/dist/src/gate/index.js.map +1 -0
  31. package/dist/src/gate/types.d.ts +82 -0
  32. package/dist/src/gate/types.d.ts.map +1 -0
  33. package/dist/src/gate/types.js +9 -0
  34. package/dist/src/gate/types.js.map +1 -0
  35. package/dist/src/index.d.ts +17 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +141 -0
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
  41. package/dist/src/mcpl/channel-registry.js +647 -0
  42. package/dist/src/mcpl/channel-registry.js.map +1 -0
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
  45. package/dist/src/mcpl/checkpoint-manager.js +430 -0
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
  47. package/dist/src/mcpl/errors.d.ts +52 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -0
  49. package/dist/src/mcpl/errors.js +93 -0
  50. package/dist/src/mcpl/errors.js.map +1 -0
  51. package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
  53. package/dist/src/mcpl/feature-set-manager.js +250 -0
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -0
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
  57. package/dist/src/mcpl/hook-orchestrator.js +217 -0
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
  59. package/dist/src/mcpl/index.d.ts +14 -0
  60. package/dist/src/mcpl/index.d.ts.map +1 -0
  61. package/dist/src/mcpl/index.js +21 -0
  62. package/dist/src/mcpl/index.js.map +1 -0
  63. package/dist/src/mcpl/inference-router.d.ts +67 -0
  64. package/dist/src/mcpl/inference-router.d.ts.map +1 -0
  65. package/dist/src/mcpl/inference-router.js +249 -0
  66. package/dist/src/mcpl/inference-router.js.map +1 -0
  67. package/dist/src/mcpl/push-handler.d.ts +60 -0
  68. package/dist/src/mcpl/push-handler.d.ts.map +1 -0
  69. package/dist/src/mcpl/push-handler.js +162 -0
  70. package/dist/src/mcpl/push-handler.js.map +1 -0
  71. package/dist/src/mcpl/scope-manager.d.ts +76 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
  73. package/dist/src/mcpl/scope-manager.js +213 -0
  74. package/dist/src/mcpl/scope-manager.js.map +1 -0
  75. package/dist/src/mcpl/server-connection.d.ts +174 -0
  76. package/dist/src/mcpl/server-connection.d.ts.map +1 -0
  77. package/dist/src/mcpl/server-connection.js +559 -0
  78. package/dist/src/mcpl/server-connection.js.map +1 -0
  79. package/dist/src/mcpl/server-registry.d.ts +63 -0
  80. package/dist/src/mcpl/server-registry.d.ts.map +1 -0
  81. package/dist/src/mcpl/server-registry.js +119 -0
  82. package/dist/src/mcpl/server-registry.js.map +1 -0
  83. package/dist/src/mcpl/types.d.ts +755 -0
  84. package/dist/src/mcpl/types.d.ts.map +1 -0
  85. package/dist/src/mcpl/types.js +48 -0
  86. package/dist/src/mcpl/types.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +93 -0
  88. package/dist/src/module-registry.d.ts.map +1 -0
  89. package/dist/src/module-registry.js +386 -0
  90. package/dist/src/module-registry.js.map +1 -0
  91. package/dist/src/modules/api/index.d.ts +22 -0
  92. package/dist/src/modules/api/index.d.ts.map +1 -0
  93. package/dist/src/modules/api/index.js +63 -0
  94. package/dist/src/modules/api/index.js.map +1 -0
  95. package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
  96. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
  97. package/dist/src/modules/discord/discord-js-client.js +273 -0
  98. package/dist/src/modules/discord/discord-js-client.js.map +1 -0
  99. package/dist/src/modules/discord/index.d.ts +82 -0
  100. package/dist/src/modules/discord/index.d.ts.map +1 -0
  101. package/dist/src/modules/discord/index.js +959 -0
  102. package/dist/src/modules/discord/index.js.map +1 -0
  103. package/dist/src/modules/discord/types.d.ts +327 -0
  104. package/dist/src/modules/discord/types.d.ts.map +1 -0
  105. package/dist/src/modules/discord/types.js +5 -0
  106. package/dist/src/modules/discord/types.js.map +1 -0
  107. package/dist/src/modules/index.d.ts +11 -0
  108. package/dist/src/modules/index.d.ts.map +1 -0
  109. package/dist/src/modules/index.js +7 -0
  110. package/dist/src/modules/index.js.map +1 -0
  111. package/dist/src/modules/workspace/index.d.ts +59 -0
  112. package/dist/src/modules/workspace/index.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/index.js +831 -0
  114. package/dist/src/modules/workspace/index.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +48 -0
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -0
  117. package/dist/src/modules/workspace/sync.js +253 -0
  118. package/dist/src/modules/workspace/sync.js.map +1 -0
  119. package/dist/src/modules/workspace/types.d.ts +166 -0
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -0
  121. package/dist/src/modules/workspace/types.js +5 -0
  122. package/dist/src/modules/workspace/types.js.map +1 -0
  123. package/dist/src/modules/workspace/watcher.d.ts +43 -0
  124. package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
  125. package/dist/src/modules/workspace/watcher.js +120 -0
  126. package/dist/src/modules/workspace/watcher.js.map +1 -0
  127. package/dist/src/queue.d.ts +50 -0
  128. package/dist/src/queue.d.ts.map +1 -0
  129. package/dist/src/queue.js +95 -0
  130. package/dist/src/queue.js.map +1 -0
  131. package/dist/src/safe-slice.d.ts +8 -0
  132. package/dist/src/safe-slice.d.ts.map +1 -0
  133. package/dist/src/safe-slice.js +18 -0
  134. package/dist/src/safe-slice.js.map +1 -0
  135. package/dist/src/types/agent.d.ts +118 -0
  136. package/dist/src/types/agent.d.ts.map +1 -0
  137. package/dist/src/types/agent.js +2 -0
  138. package/dist/src/types/agent.js.map +1 -0
  139. package/dist/src/types/events.d.ts +220 -0
  140. package/dist/src/types/events.d.ts.map +1 -0
  141. package/dist/src/types/events.js +2 -0
  142. package/dist/src/types/events.js.map +1 -0
  143. package/dist/src/types/framework.d.ts +184 -0
  144. package/dist/src/types/framework.d.ts.map +1 -0
  145. package/dist/src/types/framework.js +2 -0
  146. package/dist/src/types/framework.js.map +1 -0
  147. package/dist/src/types/index.d.ts +7 -0
  148. package/dist/src/types/index.d.ts.map +1 -0
  149. package/dist/src/types/index.js +7 -0
  150. package/dist/src/types/index.js.map +1 -0
  151. package/dist/src/types/inference-log.d.ts +64 -0
  152. package/dist/src/types/inference-log.d.ts.map +1 -0
  153. package/dist/src/types/inference-log.js +5 -0
  154. package/dist/src/types/inference-log.js.map +1 -0
  155. package/dist/src/types/module.d.ts +302 -0
  156. package/dist/src/types/module.d.ts.map +1 -0
  157. package/dist/src/types/module.js +2 -0
  158. package/dist/src/types/module.js.map +1 -0
  159. package/dist/src/types/trace.d.ts +173 -0
  160. package/dist/src/types/trace.d.ts.map +1 -0
  161. package/dist/src/types/trace.js +2 -0
  162. package/dist/src/types/trace.js.map +1 -0
  163. package/dist/test/event-gate.test.d.ts +2 -0
  164. package/dist/test/event-gate.test.d.ts.map +1 -0
  165. package/dist/test/event-gate.test.js +535 -0
  166. package/dist/test/event-gate.test.js.map +1 -0
  167. package/dist/test/framework.test.d.ts +5 -0
  168. package/dist/test/framework.test.d.ts.map +1 -0
  169. package/dist/test/framework.test.js +738 -0
  170. package/dist/test/framework.test.js.map +1 -0
  171. package/dist/tsconfig.tsbuildinfo +1 -0
  172. package/package.json +58 -0
  173. package/src/agent.ts +512 -0
  174. package/src/api/mcp-server.ts +687 -0
  175. package/src/api/server.ts +1023 -0
  176. package/src/api/types.ts +542 -0
  177. package/src/framework.ts +2784 -0
  178. package/src/gate/event-gate.ts +623 -0
  179. package/src/gate/index.ts +12 -0
  180. package/src/gate/types.ts +112 -0
  181. package/src/index.ts +63 -0
  182. package/src/mcpl/channel-registry.ts +824 -0
  183. package/src/mcpl/checkpoint-manager.ts +545 -0
  184. package/src/mcpl/errors.ts +114 -0
  185. package/src/mcpl/feature-set-manager.ts +333 -0
  186. package/src/mcpl/hook-orchestrator.ts +281 -0
  187. package/src/mcpl/index.ts +134 -0
  188. package/src/mcpl/inference-router.ts +335 -0
  189. package/src/mcpl/push-handler.ts +232 -0
  190. package/src/mcpl/scope-manager.ts +262 -0
  191. package/src/mcpl/server-connection.ts +694 -0
  192. package/src/mcpl/server-registry.ts +144 -0
  193. package/src/mcpl/types.ts +991 -0
  194. package/src/module-registry.ts +547 -0
  195. package/src/modules/api/index.ts +94 -0
  196. package/src/modules/discord/discord-js-client.ts +353 -0
  197. package/src/modules/discord/index.ts +1146 -0
  198. package/src/modules/discord/types.ts +465 -0
  199. package/src/modules/index.ts +43 -0
  200. package/src/modules/workspace/index.ts +969 -0
  201. package/src/modules/workspace/sync.ts +314 -0
  202. package/src/modules/workspace/types.ts +202 -0
  203. package/src/modules/workspace/watcher.ts +142 -0
  204. package/src/queue.ts +106 -0
  205. package/src/safe-slice.ts +18 -0
  206. package/src/types/agent.ts +119 -0
  207. package/src/types/events.ts +252 -0
  208. package/src/types/framework.ts +211 -0
  209. package/src/types/index.ts +6 -0
  210. package/src/types/inference-log.ts +64 -0
  211. package/src/types/module.ts +367 -0
  212. package/src/types/trace.ts +196 -0
@@ -0,0 +1,694 @@
1
+ /**
2
+ * McplServerConnection — manages a single JSON-RPC 2.0 connection to an MCPL server.
3
+ *
4
+ * Spawns a child process (stdio transport), performs the MCP initialize handshake
5
+ * with MCPL capability negotiation, and provides typed methods for all outbound
6
+ * MCPL messages plus EventEmitter events for inbound messages.
7
+ */
8
+
9
+ import { spawn, type ChildProcess } from 'node:child_process';
10
+ import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
11
+ import { EventEmitter } from 'node:events';
12
+
13
+ import type {
14
+ McplServerConfig,
15
+ McplCapabilities,
16
+ McplHostCapabilities,
17
+ JsonRpcRequest,
18
+ JsonRpcResponse,
19
+ BeforeInferenceParams,
20
+ BeforeInferenceResult,
21
+ AfterInferenceParams,
22
+ AfterInferenceResult,
23
+ FeatureSetsUpdateParams,
24
+ InferenceChunkParams,
25
+ StateRollbackParams,
26
+ StateRollbackResult,
27
+ ChannelsOpenParams,
28
+ ChannelsOpenResult,
29
+ ChannelsCloseParams,
30
+ ChannelsCloseResult,
31
+ ChannelsListResult,
32
+ ChannelsPublishParams,
33
+ ChannelsPublishResult,
34
+ ChannelsOutgoingChunkParams,
35
+ ChannelsOutgoingCompleteParams,
36
+ McpToolDefinition,
37
+ McpToolCallResult,
38
+ } from './types.js';
39
+
40
+ import { McplMethod } from './types.js';
41
+
42
+ /** Timeout for the initialize handshake in milliseconds.
43
+ * Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
44
+ const INITIALIZE_TIMEOUT_MS = 30_000;
45
+
46
+ /** MCP protocol version used in the initialize handshake. */
47
+ const MCP_PROTOCOL_VERSION = '2024-11-05';
48
+
49
+ /**
50
+ * Represents a pending JSON-RPC request awaiting a response.
51
+ */
52
+ interface PendingRequest {
53
+ resolve: (result: unknown) => void;
54
+ reject: (error: Error) => void;
55
+ method: string;
56
+ }
57
+
58
+ /**
59
+ * McplServerConnection manages a single JSON-RPC 2.0 connection to an
60
+ * MCPL server over stdio. Use the static `connect()` factory to create
61
+ * and initialize a connection.
62
+ *
63
+ * Events emitted:
64
+ * - `'push-event'` — Server sent `push/event`
65
+ * - `'inference-request'` — Server sent `inference/request`
66
+ * - `'scope-elevate'` — Server sent `scope/elevate`
67
+ * - `'channels-register'` — Server sent `channels/register`
68
+ * - `'channels-changed'` — Server sent `channels/changed`
69
+ * - `'channels-incoming'` — Server sent `channels/incoming`
70
+ * - `'feature-sets-changed'` — Server sent `featureSets/changed`
71
+ * - `'error'` — Connection-level error
72
+ * - `'close'` — Connection closed
73
+ */
74
+ export class McplServerConnection extends EventEmitter {
75
+ /** Unique server identifier from config. */
76
+ readonly id: string;
77
+
78
+ /** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
79
+ capabilities: McplCapabilities | null;
80
+
81
+ private process: ChildProcess;
82
+ private readline: ReadlineInterface;
83
+ private nextRequestId = 1;
84
+ private pendingRequests = new Map<string | number, PendingRequest>();
85
+ private closed = false;
86
+
87
+ // Event buffering: events emitted before ready() are queued, not lost
88
+ private readyFlag = false;
89
+ private bufferedEvents: Array<{ event: string; args: unknown[] }> = [];
90
+
91
+ // Reconnect state (adapted from Anarchid/agent-framework@mcpl-module-proto)
92
+ private config: McplServerConfig | null = null;
93
+ private hostCapabilities: McplHostCapabilities | null = null;
94
+ private reconnectEnabled = false;
95
+ private reconnectIntervalMs = 5000;
96
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
97
+
98
+ /**
99
+ * Private constructor. Use `McplServerConnection.connect()` instead.
100
+ */
101
+ private constructor(
102
+ id: string,
103
+ capabilities: McplCapabilities | null,
104
+ childProcess: ChildProcess,
105
+ readline: ReadlineInterface,
106
+ ) {
107
+ super();
108
+ this.id = id;
109
+ this.capabilities = capabilities;
110
+ this.process = childProcess;
111
+ this.readline = readline;
112
+
113
+ // Skip setup for disconnected stubs (process/readline are null)
114
+ if (childProcess && readline) {
115
+ this.setupMessageRouting();
116
+ this.setupLifecycle();
117
+ }
118
+ }
119
+
120
+ // ==========================================================================
121
+ // Event buffering
122
+ // ==========================================================================
123
+
124
+ /**
125
+ * Mark the connection as ready — flushes any events that arrived between
126
+ * construction (when setupMessageRouting starts emitting) and now (when
127
+ * the caller has attached listeners via wireMcplEvents).
128
+ */
129
+ ready(): void {
130
+ this.readyFlag = true;
131
+ for (const { event, args } of this.bufferedEvents) {
132
+ super.emit(event, ...args);
133
+ }
134
+ this.bufferedEvents = [];
135
+ }
136
+
137
+ /**
138
+ * Override emit to buffer server→host events until ready() is called.
139
+ * Lifecycle events ('close', 'error', 'reconnect') always pass through.
140
+ */
141
+ override emit(event: string | symbol, ...args: unknown[]): boolean {
142
+ const name = typeof event === 'string' ? event : '';
143
+ if (this.readyFlag || name === 'close' || name === 'error' || name === 'reconnect') {
144
+ return super.emit(event, ...args);
145
+ }
146
+ this.bufferedEvents.push({ event: name, args });
147
+ return true;
148
+ }
149
+
150
+ // ==========================================================================
151
+ // Static factory
152
+ // ==========================================================================
153
+
154
+ /**
155
+ * Spawn a server process, perform the MCP initialize handshake with MCPL
156
+ * capability negotiation, and return a ready-to-use connection.
157
+ *
158
+ * Throws if the process cannot be spawned or the handshake times out.
159
+ */
160
+ static async connect(
161
+ config: McplServerConfig,
162
+ hostCapabilities: McplHostCapabilities,
163
+ ): Promise<McplServerConnection> {
164
+ const child = spawn(config.command, config.args ?? [], {
165
+ stdio: ['pipe', 'pipe', 'pipe'],
166
+ env: { ...process.env, ...config.env },
167
+ });
168
+
169
+ // Fail fast if the process exits before the handshake completes
170
+ const earlyExitPromise = new Promise<never>((_resolve, reject) => {
171
+ child.on('error', (err) => {
172
+ reject(new Error(`Failed to spawn MCPL server "${config.id}": ${err.message}`));
173
+ });
174
+ child.on('exit', (code) => {
175
+ reject(new Error(`MCPL server "${config.id}" exited before handshake (code ${code})`));
176
+ });
177
+ });
178
+
179
+ // Set up readline for newline-delimited JSON on stdout
180
+ const rl = createInterface({ input: child.stdout! });
181
+
182
+ // -----------------------------------------------------------------------
183
+ // Perform initialize handshake
184
+ // -----------------------------------------------------------------------
185
+
186
+ // Step 1: Send `initialize` request
187
+ const initId = 0; // use id=0 for the handshake
188
+ const initRequest: JsonRpcRequest = {
189
+ jsonrpc: '2.0',
190
+ method: 'initialize',
191
+ id: initId,
192
+ params: {
193
+ protocolVersion: MCP_PROTOCOL_VERSION,
194
+ capabilities: {
195
+ experimental: {
196
+ mcpl: hostCapabilities,
197
+ },
198
+ },
199
+ clientInfo: {
200
+ name: 'agent-framework',
201
+ version: '1.0.0',
202
+ },
203
+ },
204
+ };
205
+ child.stdin!.write(JSON.stringify(initRequest) + '\n');
206
+
207
+ // Step 2: Wait for the initialize response
208
+ const initResponse = await Promise.race([
209
+ new Promise<JsonRpcResponse>((resolve, reject) => {
210
+ const onLine = (line: string) => {
211
+ try {
212
+ const msg = JSON.parse(line) as JsonRpcResponse;
213
+ if (msg.id === initId) {
214
+ rl.off('line', onLine);
215
+ if (msg.error) {
216
+ reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
217
+ } else {
218
+ resolve(msg);
219
+ }
220
+ }
221
+ } catch {
222
+ // Ignore non-JSON lines (e.g. logback output from Java servers)
223
+ }
224
+ };
225
+ rl.on('line', onLine);
226
+ }),
227
+ earlyExitPromise,
228
+ new Promise<never>((_resolve, reject) =>
229
+ setTimeout(() => {
230
+ reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`));
231
+ }, INITIALIZE_TIMEOUT_MS),
232
+ ),
233
+ ]);
234
+
235
+ // Parse MCPL capabilities from the response
236
+ const result = initResponse.result as Record<string, unknown> | undefined;
237
+ const caps = result?.capabilities as Record<string, unknown> | undefined;
238
+ const experimental = caps?.experimental as Record<string, unknown> | undefined;
239
+ const mcplCaps = (experimental?.mcpl as McplCapabilities) ?? null;
240
+
241
+ // Step 3: Send `initialized` notification (no id)
242
+ const initializedNotification: JsonRpcRequest = {
243
+ jsonrpc: '2.0',
244
+ method: 'notifications/initialized',
245
+ };
246
+ child.stdin!.write(JSON.stringify(initializedNotification) + '\n');
247
+
248
+ // Remove the early-exit listener so normal close handling takes over
249
+ child.removeAllListeners('exit');
250
+ child.removeAllListeners('error');
251
+
252
+ const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
253
+
254
+ // Store config for reconnection
255
+ if (config.reconnect) {
256
+ connection.config = config;
257
+ connection.hostCapabilities = hostCapabilities;
258
+ connection.reconnectEnabled = true;
259
+ connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
260
+ }
261
+
262
+ return connection;
263
+ }
264
+
265
+ /**
266
+ * Connect with reconnect support.
267
+ * When `config.reconnect` is true and the initial connection fails,
268
+ * resolves immediately with null capabilities and retries in the background.
269
+ * Adapted from Anarchid/agent-framework@mcpl-module-proto.
270
+ */
271
+ static async connectWithReconnect(
272
+ config: McplServerConfig,
273
+ hostCapabilities: McplHostCapabilities,
274
+ ): Promise<McplServerConnection> {
275
+ try {
276
+ return await McplServerConnection.connect(config, hostCapabilities);
277
+ } catch (error) {
278
+ if (!config.reconnect) {
279
+ throw error;
280
+ }
281
+
282
+ // Non-blocking start: create a disconnected stub that will reconnect in background
283
+ console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message);
284
+ return McplServerConnection.createDisconnectedStub(config, hostCapabilities);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Create a disconnected stub connection that will reconnect in the background.
290
+ * Used when initial connect fails and reconnect is enabled.
291
+ * @internal
292
+ */
293
+ private static createDisconnectedStub(
294
+ config: McplServerConfig,
295
+ hostCapabilities: McplHostCapabilities,
296
+ ): McplServerConnection {
297
+ // Use null! for process/readline since the connection is closed
298
+ const stub = new McplServerConnection(config.id, null, null! as ChildProcess, null! as ReadlineInterface);
299
+ stub.closed = true;
300
+ stub.config = config;
301
+ stub.hostCapabilities = hostCapabilities;
302
+ stub.reconnectEnabled = true;
303
+ stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
304
+
305
+ // Schedule background reconnect
306
+ stub.scheduleReconnect();
307
+
308
+ return stub;
309
+ }
310
+
311
+ /**
312
+ * Extract internals for transfer during reconnection.
313
+ * Strips the source connection — caller takes ownership of the process/readline.
314
+ * @internal Used only by attemptReconnect().
315
+ */
316
+ extractInternals(): { process: ChildProcess; readline: ReadlineInterface } {
317
+ const result = { process: this.process, readline: this.readline };
318
+ // Prevent the source connection from killing the transferred process
319
+ this.closed = true;
320
+ return result;
321
+ }
322
+
323
+ // ==========================================================================
324
+ // Outbound requests (return Promises)
325
+ // ==========================================================================
326
+
327
+ /** Send `context/beforeInference` and await result. */
328
+ sendBeforeInference(params: BeforeInferenceParams): Promise<BeforeInferenceResult> {
329
+ return this.sendRequest(McplMethod.BeforeInference, params as unknown as Record<string, unknown>) as Promise<BeforeInferenceResult>;
330
+ }
331
+
332
+ /**
333
+ * Send `context/afterInference`.
334
+ * When `blocking` is true, sends as a request and awaits the result.
335
+ * When `blocking` is false (or omitted), sends as a notification.
336
+ */
337
+ sendAfterInference(params: AfterInferenceParams, blocking?: boolean): Promise<AfterInferenceResult | void> {
338
+ if (blocking) {
339
+ return this.sendRequest(McplMethod.AfterInference, params as unknown as Record<string, unknown>) as Promise<AfterInferenceResult>;
340
+ }
341
+ this.sendNotification(McplMethod.AfterInference, params as unknown as Record<string, unknown>);
342
+ return Promise.resolve();
343
+ }
344
+
345
+ /** Send `featureSets/update` notification. */
346
+ sendFeatureSetsUpdate(params: FeatureSetsUpdateParams): void {
347
+ this.sendNotification(McplMethod.FeatureSetsUpdate, params as unknown as Record<string, unknown>);
348
+ }
349
+
350
+ /** Send `inference/chunk` notification. */
351
+ sendInferenceChunk(params: InferenceChunkParams): void {
352
+ this.sendNotification(McplMethod.InferenceChunk, params as unknown as Record<string, unknown>);
353
+ }
354
+
355
+ /** Send `state/rollback` request and await result. */
356
+ sendStateRollback(params: StateRollbackParams): Promise<StateRollbackResult> {
357
+ return this.sendRequest(McplMethod.StateRollback, params as unknown as Record<string, unknown>) as Promise<StateRollbackResult>;
358
+ }
359
+
360
+ /** Send `channels/open` request and await result. */
361
+ sendChannelsOpen(params: ChannelsOpenParams): Promise<ChannelsOpenResult> {
362
+ return this.sendRequest(McplMethod.ChannelsOpen, params as unknown as Record<string, unknown>) as Promise<ChannelsOpenResult>;
363
+ }
364
+
365
+ /** Send `channels/close` request and await result. */
366
+ sendChannelsClose(params: ChannelsCloseParams): Promise<ChannelsCloseResult> {
367
+ return this.sendRequest(McplMethod.ChannelsClose, params as unknown as Record<string, unknown>) as Promise<ChannelsCloseResult>;
368
+ }
369
+
370
+ /** Send `channels/list` request and await result. */
371
+ sendChannelsList(): Promise<ChannelsListResult> {
372
+ return this.sendRequest(McplMethod.ChannelsList, {}) as Promise<ChannelsListResult>;
373
+ }
374
+
375
+ /**
376
+ * Send `channels/publish`.
377
+ * May be sent as a request (if an ACK is desired) or notification.
378
+ * When `params.stream` is true, sends as a notification (no ACK).
379
+ */
380
+ sendChannelsPublish(params: ChannelsPublishParams): Promise<ChannelsPublishResult | void> {
381
+ if (params.stream) {
382
+ this.sendNotification(McplMethod.ChannelsPublish, params as unknown as Record<string, unknown>);
383
+ return Promise.resolve();
384
+ }
385
+ return this.sendRequest(McplMethod.ChannelsPublish, params as unknown as Record<string, unknown>) as Promise<ChannelsPublishResult>;
386
+ }
387
+
388
+ /** Send `channels/outgoing/chunk` notification. */
389
+ sendChannelsOutgoingChunk(params: ChannelsOutgoingChunkParams): void {
390
+ this.sendNotification(McplMethod.ChannelsOutgoingChunk, params as unknown as Record<string, unknown>);
391
+ }
392
+
393
+ /** Send `channels/outgoing/complete` notification. */
394
+ sendChannelsOutgoingComplete(params: ChannelsOutgoingCompleteParams): void {
395
+ this.sendNotification(McplMethod.ChannelsOutgoingComplete, params as unknown as Record<string, unknown>);
396
+ }
397
+
398
+ /** Send `channels/typing` notification (best-effort). */
399
+ sendChannelsTyping(channelId: string): void {
400
+ this.sendNotification(McplMethod.ChannelsTyping, { channelId });
401
+ }
402
+
403
+ // ==========================================================================
404
+ // Standard MCP methods
405
+ // ==========================================================================
406
+
407
+ /** Send `tools/list` and return the server's tool definitions. */
408
+ sendToolsList(): Promise<{ tools: McpToolDefinition[] }> {
409
+ return this.sendRequest('tools/list', {}) as Promise<{ tools: McpToolDefinition[] }>;
410
+ }
411
+
412
+ /** Send `tools/call` and return the result. Optionally includes state/checkpoint for stateful tools. */
413
+ sendToolsCall(
414
+ name: string,
415
+ args: Record<string, unknown>,
416
+ stateParams?: { state?: unknown; checkpoint?: string },
417
+ ): Promise<McpToolCallResult> {
418
+ const params: Record<string, unknown> = { name, arguments: args };
419
+ if (stateParams?.state !== undefined) params.state = stateParams.state;
420
+ if (stateParams?.checkpoint !== undefined) params.checkpoint = stateParams.checkpoint;
421
+ return this.sendRequest('tools/call', params) as Promise<McpToolCallResult>;
422
+ }
423
+
424
+ // ==========================================================================
425
+ // Lifecycle
426
+ // ==========================================================================
427
+
428
+ /** Close the connection: disable reconnect, kill the child process, and clean up resources. */
429
+ async close(): Promise<void> {
430
+ // Disable reconnect before closing — explicit close means stop retrying
431
+ this.reconnectEnabled = false;
432
+ if (this.reconnectTimer) {
433
+ clearTimeout(this.reconnectTimer);
434
+ this.reconnectTimer = null;
435
+ }
436
+
437
+ if (this.closed) {
438
+ return;
439
+ }
440
+ this.closed = true;
441
+
442
+ // Reject all pending requests
443
+ for (const [id, pending] of this.pendingRequests) {
444
+ pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
445
+ }
446
+ this.pendingRequests.clear();
447
+
448
+ // Close readline
449
+ if (this.readline) {
450
+ this.readline.close();
451
+ }
452
+
453
+ // Kill the child process
454
+ if (this.process && !this.process.killed) {
455
+ this.process.kill();
456
+ }
457
+
458
+ // Wait for process to actually exit
459
+ if (this.process) {
460
+ await new Promise<void>((resolve) => {
461
+ if (this.process.exitCode !== null || this.process.killed) {
462
+ resolve();
463
+ } else {
464
+ this.process.once('exit', () => resolve());
465
+ }
466
+ });
467
+ }
468
+
469
+ this.emit('close');
470
+ }
471
+
472
+ /**
473
+ * Schedule a background reconnection attempt.
474
+ */
475
+ private scheduleReconnect(): void {
476
+ if (!this.reconnectEnabled || this.reconnectTimer) return;
477
+
478
+ this.reconnectTimer = setTimeout(() => {
479
+ this.reconnectTimer = null;
480
+ this.attemptReconnect();
481
+ }, this.reconnectIntervalMs);
482
+ }
483
+
484
+ /**
485
+ * Attempt to reconnect by spawning a new process and performing the handshake.
486
+ */
487
+ private async attemptReconnect(): Promise<void> {
488
+ if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
489
+
490
+ try {
491
+ const fresh = await McplServerConnection.connect(this.config, this.hostCapabilities);
492
+ const internals = fresh.extractInternals();
493
+
494
+ // Transfer state from fresh connection to this instance
495
+ this.process = internals.process;
496
+ this.readline = internals.readline;
497
+ this.capabilities = fresh.capabilities;
498
+ this.closed = false;
499
+ this.nextRequestId = 1;
500
+ this.pendingRequests.clear();
501
+
502
+ // Re-wire message routing and lifecycle on the new process
503
+ this.setupMessageRouting();
504
+ this.setupLifecycle();
505
+ this.readyFlag = true; // Listeners already attached from initial wire
506
+
507
+ console.error(`MCPL server "${this.id}" reconnected successfully`);
508
+ this.emit('reconnect');
509
+ } catch (error) {
510
+ console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message);
511
+ this.scheduleReconnect();
512
+ }
513
+ }
514
+
515
+ // ==========================================================================
516
+ // Private: message sending
517
+ // ==========================================================================
518
+
519
+ /**
520
+ * Send a JSON-RPC request and return a promise that resolves with the result
521
+ * or rejects with a JSON-RPC error.
522
+ */
523
+ private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
524
+ if (this.closed) {
525
+ return Promise.reject(new Error(`Cannot send request: connection to "${this.id}" is closed`));
526
+ }
527
+
528
+ const id = this.nextRequestId++;
529
+ const request: JsonRpcRequest = {
530
+ jsonrpc: '2.0',
531
+ method,
532
+ id,
533
+ params,
534
+ };
535
+
536
+ return new Promise((resolve, reject) => {
537
+ this.pendingRequests.set(id, { resolve, reject, method });
538
+ this.process.stdin!.write(JSON.stringify(request) + '\n');
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Send a JSON-RPC notification (no `id`, no response expected).
544
+ */
545
+ private sendNotification(method: string, params: Record<string, unknown>): void {
546
+ if (this.closed) {
547
+ return;
548
+ }
549
+
550
+ const notification: JsonRpcRequest = {
551
+ jsonrpc: '2.0',
552
+ method,
553
+ params,
554
+ };
555
+ this.process.stdin!.write(JSON.stringify(notification) + '\n');
556
+ }
557
+
558
+ // ==========================================================================
559
+ // Private: inbound message routing
560
+ // ==========================================================================
561
+
562
+ /**
563
+ * Map from JSON-RPC method name to the EventEmitter event name.
564
+ */
565
+ private static readonly METHOD_TO_EVENT: Record<string, string> = {
566
+ [McplMethod.PushEvent]: 'push-event',
567
+ [McplMethod.InferenceRequest]: 'inference-request',
568
+ [McplMethod.ScopeElevate]: 'scope-elevate',
569
+ [McplMethod.ChannelsRegister]: 'channels-register',
570
+ [McplMethod.ChannelsChanged]: 'channels-changed',
571
+ [McplMethod.ChannelsIncoming]: 'channels-incoming',
572
+ [McplMethod.FeatureSetsChanged]: 'feature-sets-changed',
573
+ 'notifications/tools/list_changed': 'tools-list-changed',
574
+ };
575
+
576
+ /**
577
+ * Wire up readline to route incoming JSON-RPC messages.
578
+ */
579
+ private setupMessageRouting(): void {
580
+ this.readline.on('line', (line) => {
581
+ let msg: JsonRpcRequest | JsonRpcResponse;
582
+ try {
583
+ msg = JSON.parse(line);
584
+ } catch {
585
+ // Ignore non-JSON lines
586
+ return;
587
+ }
588
+
589
+ // Is this a response to one of our outbound requests?
590
+ if ('id' in msg && msg.id != null && !('method' in msg)) {
591
+ this.handleResponse(msg as JsonRpcResponse);
592
+ return;
593
+ }
594
+
595
+ // It is an inbound request or notification from the server
596
+ const request = msg as JsonRpcRequest;
597
+ const eventName = McplServerConnection.METHOD_TO_EVENT[request.method];
598
+
599
+ if (eventName) {
600
+ // Emit the typed event with params and (for requests) a respond callback
601
+ if (request.id != null) {
602
+ // Server expects a response — provide a respond helper
603
+ this.emit(eventName, request.params, {
604
+ id: request.id,
605
+ respond: (result: unknown) => this.sendResponse(request.id!, result),
606
+ respondError: (code: number, message: string, data?: unknown) =>
607
+ this.sendErrorResponse(request.id!, code, message, data),
608
+ });
609
+ } else {
610
+ // Notification — no response expected
611
+ this.emit(eventName, request.params);
612
+ }
613
+ }
614
+ });
615
+ }
616
+
617
+ /**
618
+ * Handle a JSON-RPC response by resolving/rejecting the corresponding pending request.
619
+ */
620
+ private handleResponse(response: JsonRpcResponse): void {
621
+ const pending = this.pendingRequests.get(response.id);
622
+ if (!pending) {
623
+ return; // Orphaned response — ignore
624
+ }
625
+
626
+ this.pendingRequests.delete(response.id);
627
+
628
+ if (response.error) {
629
+ pending.reject(
630
+ new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`),
631
+ );
632
+ } else {
633
+ pending.resolve(response.result);
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Send a successful JSON-RPC response back to the server.
639
+ */
640
+ private sendResponse(id: string | number, result: unknown): void {
641
+ if (this.closed) return;
642
+ const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
643
+ this.process.stdin!.write(JSON.stringify(response) + '\n');
644
+ }
645
+
646
+ /**
647
+ * Send a JSON-RPC error response back to the server.
648
+ */
649
+ private sendErrorResponse(id: string | number, code: number, message: string, data?: unknown): void {
650
+ if (this.closed) return;
651
+ const response: JsonRpcResponse = {
652
+ jsonrpc: '2.0',
653
+ id,
654
+ error: { code, message, data },
655
+ };
656
+ this.process.stdin!.write(JSON.stringify(response) + '\n');
657
+ }
658
+
659
+ // ==========================================================================
660
+ // Private: lifecycle
661
+ // ==========================================================================
662
+
663
+ /**
664
+ * Set up process error/exit handlers.
665
+ */
666
+ private setupLifecycle(): void {
667
+ this.process.on('error', (err) => {
668
+ this.emit('error', new Error(`MCPL server "${this.id}" process error: ${err.message}`));
669
+ });
670
+
671
+ this.process.on('exit', (code, signal) => {
672
+ if (!this.closed) {
673
+ this.closed = true;
674
+
675
+ // Reject all pending requests
676
+ for (const [id, pending] of this.pendingRequests) {
677
+ pending.reject(
678
+ new Error(
679
+ `MCPL server "${this.id}" exited unexpectedly (code=${code}, signal=${signal}) while awaiting ${pending.method} (id=${id})`,
680
+ ),
681
+ );
682
+ }
683
+ this.pendingRequests.clear();
684
+
685
+ this.emit('close', code, signal);
686
+
687
+ // Schedule reconnect if enabled (unexpected exit triggers auto-reconnect)
688
+ if (this.reconnectEnabled) {
689
+ this.scheduleReconnect();
690
+ }
691
+ }
692
+ });
693
+ }
694
+ }