@easynet-run/node 0.27.14 → 0.36.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/native/dendrite-bridge-manifest.json +5 -4
- package/native/dendrite-bridge.json +1 -1
- package/native/include/axon_dendrite_bridge.h +18 -4
- package/native/libaxon_dendrite_bridge.so +0 -0
- package/package.json +7 -5
- package/runtime/easynet-runtime-rs-0.36.9-x86_64-unknown-linux-gnu.tar.gz +0 -0
- package/runtime/runtime-bridge-manifest.json +4 -4
- package/runtime/runtime-bridge.json +3 -3
- package/src/ability_lifecycle.d.ts +1 -0
- package/src/ability_lifecycle.js +12 -6
- package/src/capability_request.js +3 -1
- package/src/dendrite_bridge/bridge.d.ts +9 -2
- package/src/dendrite_bridge/bridge.js +54 -5
- package/src/dendrite_bridge/ffi.d.ts +3 -0
- package/src/dendrite_bridge/ffi.js +2 -0
- package/src/dendrite_bridge/types.d.ts +4 -0
- package/src/errors.js +9 -3
- package/src/index.d.ts +2 -2
- package/src/mcp/server.d.ts +24 -2
- package/src/mcp/server.js +218 -18
- package/src/mcp/server.test.js +62 -0
- package/src/presets/remote_control/config.d.ts +3 -0
- package/src/presets/remote_control/config.js +17 -3
- package/src/presets/remote_control/descriptor.js +28 -11
- package/src/presets/remote_control/handlers.d.ts +2 -0
- package/src/presets/remote_control/handlers.js +22 -17
- package/src/presets/remote_control/kit.d.ts +4 -2
- package/src/presets/remote_control/kit.js +70 -1
- package/src/presets/remote_control/kit.test.js +449 -0
- package/src/presets/remote_control/orchestrator.d.ts +5 -0
- package/src/presets/remote_control/orchestrator.js +9 -1
- package/src/presets/remote_control/specs.js +80 -61
- package/src/receipt.js +6 -3
- package/runtime/easynet-runtime-rs-0.27.14-x86_64-unknown-linux-gnu.tar.gz +0 -0
package/src/mcp/server.js
CHANGED
|
@@ -8,6 +8,7 @@ export class StdioMcpServer {
|
|
|
8
8
|
serverName;
|
|
9
9
|
serverVersion;
|
|
10
10
|
_closed = false;
|
|
11
|
+
_pendingWrite = Promise.resolve();
|
|
11
12
|
constructor(provider, options = {}) {
|
|
12
13
|
this.provider = provider;
|
|
13
14
|
this.protocolVersion = options.protocolVersion?.trim() || DEFAULT_PROTOCOL_VERSION;
|
|
@@ -20,11 +21,11 @@ export class StdioMcpServer {
|
|
|
20
21
|
run(input, output) {
|
|
21
22
|
let cache = "";
|
|
22
23
|
input.setEncoding("utf8");
|
|
24
|
+
const writeFn = (payload) => this.enqueueWrite(output, payload);
|
|
23
25
|
input.on("error", (error) => {
|
|
24
26
|
if (this._closed)
|
|
25
27
|
return;
|
|
26
|
-
|
|
27
|
-
write(output, payload);
|
|
28
|
+
void writeFn(jsonrpcError(null, -32000, `stream error: ${String(error)}`));
|
|
28
29
|
});
|
|
29
30
|
input.on("close", () => {
|
|
30
31
|
// no-op: caller controls lifecycle
|
|
@@ -34,7 +35,7 @@ export class StdioMcpServer {
|
|
|
34
35
|
return;
|
|
35
36
|
cache += chunk.toString();
|
|
36
37
|
if (cache.length > MAX_LINE_LENGTH && !cache.includes("\n")) {
|
|
37
|
-
|
|
38
|
+
void writeFn(jsonrpcError(null, -32000, `input line exceeds maximum length (${MAX_LINE_LENGTH} bytes)`));
|
|
38
39
|
cache = "";
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
@@ -47,31 +48,52 @@ export class StdioMcpServer {
|
|
|
47
48
|
if (this._closed)
|
|
48
49
|
break;
|
|
49
50
|
if (raw.length > MAX_LINE_LENGTH) {
|
|
50
|
-
|
|
51
|
+
void writeFn(jsonrpcError(null, -32000, `input line exceeds maximum length (${MAX_LINE_LENGTH} bytes)`));
|
|
51
52
|
continue;
|
|
52
53
|
}
|
|
53
54
|
const requestId = parseJson(raw)?.id ?? null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
void this.dispatchRequest(raw, writeFn, requestId);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async dispatchRequest(raw, writeFn, fallbackId) {
|
|
60
|
+
try {
|
|
61
|
+
const response = await this.handleRawLine(raw, writeFn);
|
|
62
|
+
if (response !== null && !this._closed) {
|
|
63
|
+
await writeFn(response);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!this._closed) {
|
|
68
|
+
await writeFn(jsonrpcError(fallbackId, -32000, String(error)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
enqueueWrite(output, payload) {
|
|
73
|
+
this._pendingWrite = this._pendingWrite
|
|
74
|
+
.catch(() => undefined)
|
|
75
|
+
.then(async () => {
|
|
76
|
+
if (this._closed) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await write(output, payload);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
this._closed = true;
|
|
84
|
+
throw new Error("failed to write MCP response");
|
|
64
85
|
}
|
|
65
86
|
});
|
|
87
|
+
return this._pendingWrite;
|
|
66
88
|
}
|
|
67
|
-
handleRawLine(raw) {
|
|
89
|
+
handleRawLine(raw, writeFn) {
|
|
68
90
|
const request = parseJson(raw);
|
|
69
91
|
if (request === null) {
|
|
70
92
|
return jsonrpcError(null, -32700, "parse error");
|
|
71
93
|
}
|
|
72
|
-
return this.handleRequest(request);
|
|
94
|
+
return this.handleRequest(request, writeFn);
|
|
73
95
|
}
|
|
74
|
-
handleRequest(request) {
|
|
96
|
+
handleRequest(request, writeFn) {
|
|
75
97
|
const id = request.id;
|
|
76
98
|
const method = typeof request.method === "string" ? request.method : "";
|
|
77
99
|
const params = asMap(request.params);
|
|
@@ -114,6 +136,28 @@ export class StdioMcpServer {
|
|
|
114
136
|
args = {};
|
|
115
137
|
}
|
|
116
138
|
const typedArgs = args;
|
|
139
|
+
// Try streaming path first. When a writeFn is available, chunks are
|
|
140
|
+
// forwarded to the client as JSON-RPC notifications in real time.
|
|
141
|
+
// Otherwise the stream is buffered into a single response (fallback).
|
|
142
|
+
let streamHandle = null;
|
|
143
|
+
try {
|
|
144
|
+
streamHandle = this.provider.handleToolCallStream?.(name, typedArgs) ?? null;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return jsonrpcSuccess(id, toolResponse({
|
|
148
|
+
payload: { ok: false, error: String(error) },
|
|
149
|
+
isError: true,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
if (streamHandle !== null) {
|
|
153
|
+
const maxBytes = resolveMaxBytes(typedArgs.max_bytes);
|
|
154
|
+
if (writeFn) {
|
|
155
|
+
return streamToClient(streamHandle, id, writeFn, maxBytes);
|
|
156
|
+
}
|
|
157
|
+
return consumeStream(streamHandle, maxBytes)
|
|
158
|
+
.then((result) => jsonrpcSuccess(id, toolResponse(result)))
|
|
159
|
+
.catch((error) => jsonrpcSuccess(id, toolResponse(toolErrorResult(error))));
|
|
160
|
+
}
|
|
117
161
|
let maybeResult;
|
|
118
162
|
try {
|
|
119
163
|
maybeResult = this.provider.handleToolCall(name, typedArgs);
|
|
@@ -166,7 +210,34 @@ function asString(value) {
|
|
|
166
210
|
return value.trim();
|
|
167
211
|
}
|
|
168
212
|
function write(stream, payload) {
|
|
169
|
-
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const text = `${JSON.stringify(payload)}\n`;
|
|
215
|
+
const onDrain = () => {
|
|
216
|
+
cleanup();
|
|
217
|
+
resolve();
|
|
218
|
+
};
|
|
219
|
+
const onError = (error) => {
|
|
220
|
+
cleanup();
|
|
221
|
+
reject(error);
|
|
222
|
+
};
|
|
223
|
+
const cleanup = () => {
|
|
224
|
+
stream.removeListener("drain", onDrain);
|
|
225
|
+
stream.removeListener("error", onError);
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
if (stream.write(text)) {
|
|
229
|
+
cleanup();
|
|
230
|
+
resolve();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
stream.once("drain", onDrain);
|
|
234
|
+
stream.once("error", onError);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
cleanup();
|
|
238
|
+
reject(error);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
170
241
|
}
|
|
171
242
|
function toolResponse(result) {
|
|
172
243
|
const payload = {
|
|
@@ -174,6 +245,12 @@ function toolResponse(result) {
|
|
|
174
245
|
};
|
|
175
246
|
return result.isError ? { ...payload, isError: true } : payload;
|
|
176
247
|
}
|
|
248
|
+
function toolErrorResult(error) {
|
|
249
|
+
return {
|
|
250
|
+
payload: { ok: false, error: String(error) },
|
|
251
|
+
isError: true,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
177
254
|
function jsonrpcSuccess(id, result) {
|
|
178
255
|
return {
|
|
179
256
|
jsonrpc: "2.0",
|
|
@@ -188,3 +265,126 @@ function jsonrpcError(id, code, message) {
|
|
|
188
265
|
error: { code, message },
|
|
189
266
|
};
|
|
190
267
|
}
|
|
268
|
+
function jsonrpcNotification(method, params) {
|
|
269
|
+
return { jsonrpc: "2.0", method, params };
|
|
270
|
+
}
|
|
271
|
+
/** Default maximum total bytes (64 MiB) accepted from a single streamed MCP tool call. */
|
|
272
|
+
const DEFAULT_MAX_STREAM_BYTES = 64 * 1024 * 1024; // 64 MiB
|
|
273
|
+
/** Resolve a per-call max_bytes override. 0 or absent → default. */
|
|
274
|
+
function resolveMaxBytes(raw) {
|
|
275
|
+
if (typeof raw === "number" && raw > 0)
|
|
276
|
+
return raw;
|
|
277
|
+
return DEFAULT_MAX_STREAM_BYTES;
|
|
278
|
+
}
|
|
279
|
+
function formatBytes(n) {
|
|
280
|
+
if (n >= 1024 * 1024 * 1024)
|
|
281
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(0)} GiB`;
|
|
282
|
+
if (n >= 1024 * 1024)
|
|
283
|
+
return `${(n / (1024 * 1024)).toFixed(0)} MiB`;
|
|
284
|
+
if (n >= 1024)
|
|
285
|
+
return `${(n / 1024).toFixed(0)} KiB`;
|
|
286
|
+
return `${n} bytes`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Common helper that iterates a stream handle, converts each chunk to UTF-8,
|
|
290
|
+
* validates encoding, enforces a byte limit, and closes the handle when done.
|
|
291
|
+
*
|
|
292
|
+
* - **Streaming mode** (`onChunk` provided): calls `onChunk` for each decoded
|
|
293
|
+
* chunk and does NOT accumulate chunks in memory.
|
|
294
|
+
* - **Buffer mode** (`onChunk` omitted): accumulates all decoded chunks into
|
|
295
|
+
* the returned `chunks` array.
|
|
296
|
+
*/
|
|
297
|
+
async function processStream(handle, maxBytes, onChunk) {
|
|
298
|
+
const result = {
|
|
299
|
+
hadError: null,
|
|
300
|
+
hadInvalidUtf8: false,
|
|
301
|
+
chunkCount: 0,
|
|
302
|
+
chunks: [],
|
|
303
|
+
};
|
|
304
|
+
let totalBytes = 0;
|
|
305
|
+
try {
|
|
306
|
+
for await (const raw of handle.stream) {
|
|
307
|
+
const buf = Buffer.from(raw);
|
|
308
|
+
const text = buf.toString("utf8");
|
|
309
|
+
if (!result.hadInvalidUtf8 && buf.length > 0 && text.includes('\uFFFD')) {
|
|
310
|
+
result.hadInvalidUtf8 = true;
|
|
311
|
+
}
|
|
312
|
+
totalBytes += buf.length;
|
|
313
|
+
if (totalBytes > maxBytes) {
|
|
314
|
+
const suffix = onChunk ? "limit" : "buffer limit";
|
|
315
|
+
result.hadError = `stream output exceeded ${formatBytes(maxBytes)} ${suffix}`;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (onChunk) {
|
|
319
|
+
await onChunk(text, result.chunkCount);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
result.chunks.push(text);
|
|
323
|
+
}
|
|
324
|
+
result.chunkCount++;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
result.hadError = String(error);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
try {
|
|
332
|
+
handle.close();
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
console.error("mcp: stream close failed:", e);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Stream tool output to the client in real time using JSON-RPC notifications.
|
|
342
|
+
*
|
|
343
|
+
* Each chunk is sent as an `axon/streamChunk` notification as soon as it
|
|
344
|
+
* arrives from the underlying stream. After the stream completes (or on
|
|
345
|
+
* error) a final JSON-RPC response is returned with a summary.
|
|
346
|
+
*
|
|
347
|
+
* Clients that do not understand `axon/streamChunk` simply ignore the
|
|
348
|
+
* notifications and still receive the final summary response.
|
|
349
|
+
*/
|
|
350
|
+
async function streamToClient(handle, requestId, writeFn, maxBytes = DEFAULT_MAX_STREAM_BYTES) {
|
|
351
|
+
const result = await processStream(handle, maxBytes, async (decoded, seq) => {
|
|
352
|
+
await writeFn(jsonrpcNotification("axon/streamChunk", {
|
|
353
|
+
requestId,
|
|
354
|
+
seq,
|
|
355
|
+
chunk: decoded,
|
|
356
|
+
}));
|
|
357
|
+
});
|
|
358
|
+
const summary = {
|
|
359
|
+
ok: result.hadError === null,
|
|
360
|
+
chunk_count: result.chunkCount,
|
|
361
|
+
streamed: true,
|
|
362
|
+
};
|
|
363
|
+
if (result.hadError)
|
|
364
|
+
summary.error = result.hadError;
|
|
365
|
+
if (result.hadInvalidUtf8)
|
|
366
|
+
summary.contains_invalid_utf8 = true;
|
|
367
|
+
return jsonrpcSuccess(requestId, toolResponse({
|
|
368
|
+
payload: summary,
|
|
369
|
+
isError: result.hadError !== null,
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Buffer all chunks from a streaming MCP tool handle into a single result.
|
|
374
|
+
* Stops accumulating after `maxBytes` (default 64 MiB) to prevent unbounded
|
|
375
|
+
* memory growth. Always closes the handle when done, even on error.
|
|
376
|
+
*/
|
|
377
|
+
export async function consumeStream(handle, maxBytes = DEFAULT_MAX_STREAM_BYTES) {
|
|
378
|
+
const result = await processStream(handle, maxBytes);
|
|
379
|
+
if (result.hadError !== null) {
|
|
380
|
+
return {
|
|
381
|
+
payload: { ok: false, chunk_count: result.chunks.length, chunks: result.chunks, error: result.hadError },
|
|
382
|
+
isError: true,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const payload = { ok: true, chunk_count: result.chunks.length, chunks: result.chunks };
|
|
386
|
+
if (result.hadInvalidUtf8) {
|
|
387
|
+
payload.contains_invalid_utf8 = true;
|
|
388
|
+
}
|
|
389
|
+
return { payload, isError: false };
|
|
390
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
|
|
5
|
+
import { StdioMcpServer } from "./server.js";
|
|
6
|
+
|
|
7
|
+
describe("StdioMcpServer", () => {
|
|
8
|
+
it("keeps buffered stream responses intact when close throws", async () => {
|
|
9
|
+
const server = new StdioMcpServer({
|
|
10
|
+
toolSpecs: () => [],
|
|
11
|
+
handleToolCall: () => ({ payload: { ok: true }, isError: false }),
|
|
12
|
+
handleToolCallStream: () => ({
|
|
13
|
+
close() {
|
|
14
|
+
throw new Error("close failed");
|
|
15
|
+
},
|
|
16
|
+
async *[Symbol.asyncIterator]() {
|
|
17
|
+
yield Buffer.from("chunk-1");
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const response = await server.handleRequest({
|
|
23
|
+
jsonrpc: "2.0",
|
|
24
|
+
id: 1,
|
|
25
|
+
method: "tools/call",
|
|
26
|
+
params: {
|
|
27
|
+
name: "streamed_tool",
|
|
28
|
+
arguments: {},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(response.jsonrpc, "2.0");
|
|
33
|
+
const payload = JSON.parse(response.result.content[0].text);
|
|
34
|
+
assert.equal(payload.ok, true);
|
|
35
|
+
assert.deepEqual(payload.chunks, ["chunk-1"]);
|
|
36
|
+
assert.equal(response.result.isError, undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("serializes writes through drain backpressure", async () => {
|
|
40
|
+
const input = new PassThrough();
|
|
41
|
+
const chunks = [];
|
|
42
|
+
const output = new PassThrough({ highWaterMark: 1 });
|
|
43
|
+
output.on("data", (chunk) => {
|
|
44
|
+
chunks.push(chunk.toString("utf8"));
|
|
45
|
+
});
|
|
46
|
+
const server = new StdioMcpServer({
|
|
47
|
+
toolSpecs: () => [],
|
|
48
|
+
handleToolCall: () => ({ payload: { ok: true }, isError: false }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
server.run(input, output);
|
|
52
|
+
input.write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n');
|
|
53
|
+
input.write('{"jsonrpc":"2.0","id":2,"method":"ping"}\n');
|
|
54
|
+
input.end();
|
|
55
|
+
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
57
|
+
|
|
58
|
+
assert.equal(chunks.length, 2);
|
|
59
|
+
assert.match(chunks[0], /"id":1/);
|
|
60
|
+
assert.match(chunks[1], /"id":2/);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -8,9 +8,12 @@ export declare const DEFAULT_SIGNATURE = "__AXON_EPHEMERAL_DO_NOT_USE_IN_PROD__"
|
|
|
8
8
|
export declare const DEFAULT_VERSION = "1.0.0";
|
|
9
9
|
export declare const DEFAULT_INSTALL_TIMEOUT_SECONDS = 45;
|
|
10
10
|
export declare const DEFAULT_EXECUTION_MODE = "sandbox_first";
|
|
11
|
+
export { DEFAULT_MCP_TOOL_STREAM_TIMEOUT_MS } from "../../dendrite_bridge/ffi.js";
|
|
11
12
|
export declare function loadRemoteControlConfigFromEnv(): RemoteControlRuntimeConfig;
|
|
12
13
|
export declare function loadConfigFromEnv(): RemoteControlRuntimeConfig;
|
|
13
14
|
export declare function ensureRemoteControlNativeLibEnv(): void;
|
|
14
15
|
export declare function ensureNativeLibEnv(): void;
|
|
15
16
|
export declare function parsePositiveInt(raw: string | undefined, fallback: number): number;
|
|
16
17
|
export declare function asString(raw: unknown, fallback?: string): string;
|
|
18
|
+
export declare function asBool(raw: unknown): boolean;
|
|
19
|
+
export declare function asNumber(raw: unknown): number;
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// EasyNet Axon for AgentNet
|
|
2
2
|
// =========================
|
|
3
3
|
//
|
|
4
|
-
// File: sdk/node/src/presets/remote_control/config.
|
|
5
|
-
// Description:
|
|
4
|
+
// File: sdk/node/src/presets/remote_control/config.ts
|
|
5
|
+
// Description: Runtime configuration and constants for the remote-control preset.
|
|
6
6
|
//
|
|
7
7
|
// Author: Silan.Hu
|
|
8
8
|
// Email: silan.hu@u.nus.edu
|
|
9
9
|
// Copyright (c) 2026-2027 easynet. All rights reserved.
|
|
10
|
-
|
|
11
10
|
import { existsSync } from "node:fs";
|
|
12
11
|
import { dirname, resolve } from "node:path";
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -15,6 +14,7 @@ export const DEFAULT_SIGNATURE = "__AXON_EPHEMERAL_DO_NOT_USE_IN_PROD__";
|
|
|
15
14
|
export const DEFAULT_VERSION = "1.0.0";
|
|
16
15
|
export const DEFAULT_INSTALL_TIMEOUT_SECONDS = 45;
|
|
17
16
|
export const DEFAULT_EXECUTION_MODE = "sandbox_first";
|
|
17
|
+
export { DEFAULT_MCP_TOOL_STREAM_TIMEOUT_MS } from "../../dendrite_bridge/ffi.js";
|
|
18
18
|
export function loadRemoteControlConfigFromEnv() {
|
|
19
19
|
return {
|
|
20
20
|
endpoint: process.env.AXON_ENDPOINT ?? "http://127.0.0.1:50051",
|
|
@@ -52,6 +52,20 @@ export function parsePositiveInt(raw, fallback) {
|
|
|
52
52
|
export function asString(raw, fallback = "") {
|
|
53
53
|
return raw == null ? fallback : String(raw).trim() || fallback;
|
|
54
54
|
}
|
|
55
|
+
export function asBool(raw) {
|
|
56
|
+
if (typeof raw === "boolean")
|
|
57
|
+
return raw;
|
|
58
|
+
if (typeof raw === "number")
|
|
59
|
+
return raw !== 0;
|
|
60
|
+
const normalized = String(raw ?? "").trim().toLowerCase();
|
|
61
|
+
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
|
62
|
+
}
|
|
63
|
+
export function asNumber(raw) {
|
|
64
|
+
if (typeof raw === "number")
|
|
65
|
+
return raw;
|
|
66
|
+
const value = Number.parseInt(String(raw ?? "0"), 10);
|
|
67
|
+
return Number.isFinite(value) ? value : 0;
|
|
68
|
+
}
|
|
55
69
|
function defaultNativeLibName() {
|
|
56
70
|
if (process.platform === "darwin") {
|
|
57
71
|
return "libaxon_dendrite_bridge.dylib";
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
// EasyNet Axon for AgentNet
|
|
2
|
+
// =========================
|
|
3
|
+
//
|
|
4
|
+
// File: sdk/node/src/presets/remote_control/descriptor.ts
|
|
5
|
+
// Description: Skill package descriptor: build, parse, and serialize for MCP deployment.
|
|
6
|
+
//
|
|
7
|
+
// Author: Silan.Hu
|
|
8
|
+
// Email: silan.hu@u.nus.edu
|
|
9
|
+
// Copyright (c) 2026-2027 easynet. All rights reserved.
|
|
10
|
+
import { DEFAULT_VERSION, asBool, asString } from "./config.js";
|
|
2
11
|
export function resolveTenant(raw, fallback) {
|
|
3
12
|
const value = asString(raw);
|
|
4
13
|
return !value || value === "<nil>" ? fallback : value;
|
|
5
14
|
}
|
|
6
15
|
export function parseBool(raw) {
|
|
7
|
-
|
|
8
|
-
return raw;
|
|
9
|
-
}
|
|
10
|
-
if (typeof raw === "number") {
|
|
11
|
-
return raw !== 0;
|
|
12
|
-
}
|
|
13
|
-
const normalized = asString(raw).toLowerCase();
|
|
14
|
-
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
|
16
|
+
return asBool(raw);
|
|
15
17
|
}
|
|
16
18
|
export function randomSuffix(length = 2) {
|
|
17
19
|
return Math.floor(Math.random() * 1e18).toString(36).slice(0, length);
|
|
@@ -67,6 +69,21 @@ export function defaultOutputSchema() {
|
|
|
67
69
|
required: ["entries"],
|
|
68
70
|
};
|
|
69
71
|
}
|
|
72
|
+
function mergeAdditionalMetadata(base, raw) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
const merged = { ...base };
|
|
77
|
+
for (const [rawKey, rawValue] of Object.entries(raw)) {
|
|
78
|
+
const key = asString(rawKey);
|
|
79
|
+
const value = asString(rawValue);
|
|
80
|
+
if (!key || !value || Object.prototype.hasOwnProperty.call(merged, key)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
merged[key] = value;
|
|
84
|
+
}
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
70
87
|
export function serializeDescriptor(descriptor) {
|
|
71
88
|
const payload = {
|
|
72
89
|
"ability_name": descriptor.abilityName,
|
|
@@ -145,7 +162,7 @@ export function buildDescriptor(args, defaultSignature) {
|
|
|
145
162
|
const outputSchema = typeof args.output_schema === "object" && !Array.isArray(args.output_schema)
|
|
146
163
|
? args.output_schema
|
|
147
164
|
: defaultOutputSchema();
|
|
148
|
-
const metadata = {
|
|
165
|
+
const metadata = mergeAdditionalMetadata({
|
|
149
166
|
"mcp.tool_name": toolName,
|
|
150
167
|
"mcp.description": description,
|
|
151
168
|
"mcp.input_schema": JSON.stringify(inputSchema),
|
|
@@ -153,7 +170,7 @@ export function buildDescriptor(args, defaultSignature) {
|
|
|
153
170
|
"axon.exec.command": commandTemplate,
|
|
154
171
|
"ability.name": abilityName,
|
|
155
172
|
"ability.version": version,
|
|
156
|
-
};
|
|
173
|
+
}, args.metadata);
|
|
157
174
|
const payload = {
|
|
158
175
|
kind: "axon.ability.package.v1",
|
|
159
176
|
ability_name: abilityName,
|
|
@@ -4,6 +4,8 @@ export declare function resolveDescriptor(args: JsonRecord, fallbackSignature: s
|
|
|
4
4
|
export declare function handleDiscoverNodes(orch: RemoteOrchestrator, tenant: string): JsonRecord;
|
|
5
5
|
export declare function handleListRemoteTools(orch: RemoteOrchestrator, tenant: string, args: JsonRecord): JsonRecord;
|
|
6
6
|
export declare function handleCallRemoteTool(orch: RemoteOrchestrator, tenant: string, args: JsonRecord): JsonRecord;
|
|
7
|
+
/** Open a streaming MCP tool call with validated tool_name and node_id. */
|
|
8
|
+
export declare function handleCallRemoteToolStream(orch: RemoteOrchestrator, _tenant: string, args: JsonRecord): ReturnType<RemoteOrchestrator["callMcpToolStream"]>;
|
|
7
9
|
export declare function handleDisconnectDevice(orch: RemoteOrchestrator, tenant: string, args: JsonRecord): JsonRecord;
|
|
8
10
|
export declare function handleUninstallAbility(orch: RemoteOrchestrator, tenant: string, args: JsonRecord): JsonRecord;
|
|
9
11
|
export declare function handlePackageAbility(tenant: string, args: JsonRecord, signature: string): JsonRecord;
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
// Author: Silan.Hu
|
|
8
8
|
// Email: silan.hu@u.nus.edu
|
|
9
9
|
// Copyright (c) 2026-2027 easynet. All rights reserved.
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
10
11
|
import { buildDescriptor, buildPythonSubprocessTemplate, parseBool, parseDescriptor, serializeDescriptor, } from "./descriptor.js";
|
|
11
|
-
import { asString } from "./config.js";
|
|
12
|
+
import { asBool, asNumber, asString, DEFAULT_MCP_TOOL_STREAM_TIMEOUT_MS } from "./config.js";
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Architecture: ability_lifecycle vs presets/remote_control
|
|
14
15
|
//
|
|
@@ -79,6 +80,21 @@ export function handleCallRemoteTool(orch, tenant, args) {
|
|
|
79
80
|
const failed = asNumber(call.state) !== INVOCATION_COMPLETED || asBool(call.is_error);
|
|
80
81
|
return { ok: !failed, tenant_id: tenant, tool_name: toolName, node_id: nodeId, call };
|
|
81
82
|
}
|
|
83
|
+
/** Open a streaming MCP tool call with validated tool_name and node_id. */
|
|
84
|
+
export function handleCallRemoteToolStream(orch, _tenant, args) {
|
|
85
|
+
const toolName = String(args.tool_name || "").trim();
|
|
86
|
+
const nodeId = String(args.node_id || "").trim();
|
|
87
|
+
if (!toolName || !nodeId) {
|
|
88
|
+
throw new Error(!toolName ? "tool_name is required" : "node_id is required");
|
|
89
|
+
}
|
|
90
|
+
const callArgs = typeof args.arguments === "object" && !Array.isArray(args.arguments)
|
|
91
|
+
? args.arguments
|
|
92
|
+
: {};
|
|
93
|
+
const timeoutMs = asNumber(args.timeout_ms);
|
|
94
|
+
return orch.callMcpToolStream(toolName, nodeId, callArgs, {
|
|
95
|
+
timeoutMs: timeoutMs > 0 ? timeoutMs : DEFAULT_MCP_TOOL_STREAM_TIMEOUT_MS,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
82
98
|
export function handleDisconnectDevice(orch, tenant, args) {
|
|
83
99
|
const nodeId = String(args.node_id || "").trim();
|
|
84
100
|
if (!nodeId) {
|
|
@@ -149,7 +165,7 @@ export function handleDeployAbility(orch, tenant, args, signature) {
|
|
|
149
165
|
if (!commandTemplate) {
|
|
150
166
|
return { ok: false, tenant_id: tenant, node_id: nodeId, error: "command_template is required" };
|
|
151
167
|
}
|
|
152
|
-
const toolName = String(args.tool_name || `tool_${Date.now()}`).trim();
|
|
168
|
+
const toolName = String(args.tool_name || `tool_${Date.now()}_${randomHex(4)}`).trim();
|
|
153
169
|
const descriptor = buildDescriptor({
|
|
154
170
|
...args,
|
|
155
171
|
ability_name: toolName,
|
|
@@ -177,7 +193,7 @@ export function handleExecuteCommand(orch, tenant, args, signature) {
|
|
|
177
193
|
if (!command)
|
|
178
194
|
return { ok: false, tenant_id: tenant, node_id: nodeId, error: "command is required" };
|
|
179
195
|
const shouldCleanup = args.cleanup == null ? true : parseBool(args.cleanup);
|
|
180
|
-
const toolName = `cmd_${Date.now()}_${
|
|
196
|
+
const toolName = `cmd_${Date.now()}_${randomHex(4)}`;
|
|
181
197
|
const descriptor = buildDescriptor({
|
|
182
198
|
ability_name: toolName,
|
|
183
199
|
tool_name: toolName,
|
|
@@ -221,20 +237,6 @@ export function handleExecuteCommand(orch, tenant, args, signature) {
|
|
|
221
237
|
};
|
|
222
238
|
}
|
|
223
239
|
}
|
|
224
|
-
function asBool(raw) {
|
|
225
|
-
if (typeof raw === "boolean")
|
|
226
|
-
return raw;
|
|
227
|
-
if (typeof raw === "number")
|
|
228
|
-
return raw !== 0;
|
|
229
|
-
const normalized = String(raw ?? "").trim().toLowerCase();
|
|
230
|
-
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
|
231
|
-
}
|
|
232
|
-
function asNumber(raw) {
|
|
233
|
-
if (typeof raw === "number")
|
|
234
|
-
return raw;
|
|
235
|
-
const value = Number.parseInt(String(raw ?? "0"), 10);
|
|
236
|
-
return Number.isFinite(value) ? value : 0;
|
|
237
|
-
}
|
|
238
240
|
function parseDeployFailure(error) {
|
|
239
241
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
240
242
|
try {
|
|
@@ -277,3 +279,6 @@ function parseCleanupSummary(failure, orch, nodeId, shouldCleanup) {
|
|
|
277
279
|
},
|
|
278
280
|
]);
|
|
279
281
|
}
|
|
282
|
+
function randomHex(bytes) {
|
|
283
|
+
return randomBytes(bytes).toString("hex");
|
|
284
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type McpToolProvider, type McpToolResult, type McpToolStreamHandle } from "../../mcp/server.js";
|
|
2
2
|
import { type RemoteControlRuntimeConfig } from "./config.js";
|
|
3
3
|
import { type JsonRecord } from "./descriptor.js";
|
|
4
4
|
import { type OrchestratorFactory } from "./orchestrator.js";
|
|
@@ -14,7 +14,9 @@ export declare class RemoteControlCaseKit implements McpToolProvider {
|
|
|
14
14
|
static ensureNativeLibEnv(): void;
|
|
15
15
|
static ensureRemoteControlNativeLibEnv(): void;
|
|
16
16
|
toolSpecs(): Array<JsonRecord>;
|
|
17
|
-
handleToolCall(name: string, args: JsonRecord): McpToolResult
|
|
17
|
+
handleToolCall(name: string, args: JsonRecord): McpToolResult | Promise<McpToolResult>;
|
|
18
|
+
/** Handle streaming calls for `call_remote_tool_stream`. Returns null for unknown tools. */
|
|
19
|
+
handleToolCallStream(name: string, args: JsonRecord): McpToolStreamHandle | null;
|
|
18
20
|
private dispatch;
|
|
19
21
|
private withOrchestrator;
|
|
20
22
|
private toolResult;
|
|
@@ -1,8 +1,47 @@
|
|
|
1
|
+
import { consumeStream } from "../../mcp/server.js";
|
|
1
2
|
import { ensureRemoteControlNativeLibEnv, ensureNativeLibEnv, loadConfigFromEnv, loadRemoteControlConfigFromEnv, } from "./config.js";
|
|
2
3
|
import { resolveTenant } from "./descriptor.js";
|
|
3
|
-
import { handleCallRemoteTool, handleDeployAbility, handleDeployAbilityPackage, handleDiscoverNodes, handleDisconnectDevice, handleExecuteCommand, handleListRemoteTools, handlePackageAbility, handleUninstallAbility, } from "./handlers.js";
|
|
4
|
+
import { handleCallRemoteTool, handleCallRemoteToolStream, handleDeployAbility, handleDeployAbilityPackage, handleDiscoverNodes, handleDisconnectDevice, handleExecuteCommand, handleListRemoteTools, handlePackageAbility, handleUninstallAbility, } from "./handlers.js";
|
|
4
5
|
import { remoteControlToolSpecs } from "./specs.js";
|
|
5
6
|
import { buildOrchestrator } from "./orchestrator.js";
|
|
7
|
+
/**
|
|
8
|
+
* Wraps a remote tool stream with automatic orchestrator cleanup.
|
|
9
|
+
* Ensures the orchestrator is closed when the stream is consumed or explicitly closed.
|
|
10
|
+
* Close is idempotent — safe to call multiple times.
|
|
11
|
+
*/
|
|
12
|
+
class ManagedMcpToolStreamHandle {
|
|
13
|
+
inner;
|
|
14
|
+
cleanup;
|
|
15
|
+
stream;
|
|
16
|
+
closed = false;
|
|
17
|
+
constructor(inner, cleanup) {
|
|
18
|
+
this.inner = inner;
|
|
19
|
+
this.cleanup = cleanup;
|
|
20
|
+
this.stream = this.iterate();
|
|
21
|
+
}
|
|
22
|
+
close() {
|
|
23
|
+
if (this.closed) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.closed = true;
|
|
27
|
+
try {
|
|
28
|
+
this.inner.close();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
this.cleanup();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async *iterate() {
|
|
35
|
+
try {
|
|
36
|
+
for await (const chunk of this.inner) {
|
|
37
|
+
yield chunk;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
this.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
6
45
|
export class RemoteControlCaseKit {
|
|
7
46
|
config;
|
|
8
47
|
orchestratorFactory;
|
|
@@ -26,9 +65,39 @@ export class RemoteControlCaseKit {
|
|
|
26
65
|
return remoteControlToolSpecs();
|
|
27
66
|
}
|
|
28
67
|
handleToolCall(name, args) {
|
|
68
|
+
if (name === "call_remote_tool_stream") {
|
|
69
|
+
// Buffer streaming result so callers that bypass handleToolCallStream
|
|
70
|
+
// (e.g. direct handleToolCall invocation) still get a valid response.
|
|
71
|
+
try {
|
|
72
|
+
const handle = this.handleToolCallStream(name, args);
|
|
73
|
+
if (handle) {
|
|
74
|
+
return consumeStream(handle);
|
|
75
|
+
}
|
|
76
|
+
return this.toolResult(true, { ok: false, error: "streaming not available" });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return this.toolResult(true, { ok: false, error: String(error) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
29
82
|
const tenant = resolveTenant(args.tenant_id, this.config.tenant);
|
|
30
83
|
return this.withOrchestrator(tenant, (orchestrator) => this.dispatch(name, args, orchestrator, tenant));
|
|
31
84
|
}
|
|
85
|
+
/** Handle streaming calls for `call_remote_tool_stream`. Returns null for unknown tools. */
|
|
86
|
+
handleToolCallStream(name, args) {
|
|
87
|
+
if (name !== "call_remote_tool_stream") {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const tenant = resolveTenant(args.tenant_id, this.config.tenant);
|
|
91
|
+
const orchestrator = this.orchestratorFactory(this.config, tenant);
|
|
92
|
+
try {
|
|
93
|
+
const stream = handleCallRemoteToolStream(orchestrator, tenant, args);
|
|
94
|
+
return new ManagedMcpToolStreamHandle(stream, () => orchestrator.close());
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
orchestrator.close();
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
32
101
|
dispatch(name, args, orchestrator, tenant) {
|
|
33
102
|
switch (name) {
|
|
34
103
|
case "discover_nodes":
|