@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.
Files changed (35) hide show
  1. package/README.md +12 -0
  2. package/native/dendrite-bridge-manifest.json +5 -4
  3. package/native/dendrite-bridge.json +1 -1
  4. package/native/include/axon_dendrite_bridge.h +18 -4
  5. package/native/libaxon_dendrite_bridge.so +0 -0
  6. package/package.json +7 -5
  7. package/runtime/easynet-runtime-rs-0.36.9-x86_64-unknown-linux-gnu.tar.gz +0 -0
  8. package/runtime/runtime-bridge-manifest.json +4 -4
  9. package/runtime/runtime-bridge.json +3 -3
  10. package/src/ability_lifecycle.d.ts +1 -0
  11. package/src/ability_lifecycle.js +12 -6
  12. package/src/capability_request.js +3 -1
  13. package/src/dendrite_bridge/bridge.d.ts +9 -2
  14. package/src/dendrite_bridge/bridge.js +54 -5
  15. package/src/dendrite_bridge/ffi.d.ts +3 -0
  16. package/src/dendrite_bridge/ffi.js +2 -0
  17. package/src/dendrite_bridge/types.d.ts +4 -0
  18. package/src/errors.js +9 -3
  19. package/src/index.d.ts +2 -2
  20. package/src/mcp/server.d.ts +24 -2
  21. package/src/mcp/server.js +218 -18
  22. package/src/mcp/server.test.js +62 -0
  23. package/src/presets/remote_control/config.d.ts +3 -0
  24. package/src/presets/remote_control/config.js +17 -3
  25. package/src/presets/remote_control/descriptor.js +28 -11
  26. package/src/presets/remote_control/handlers.d.ts +2 -0
  27. package/src/presets/remote_control/handlers.js +22 -17
  28. package/src/presets/remote_control/kit.d.ts +4 -2
  29. package/src/presets/remote_control/kit.js +70 -1
  30. package/src/presets/remote_control/kit.test.js +449 -0
  31. package/src/presets/remote_control/orchestrator.d.ts +5 -0
  32. package/src/presets/remote_control/orchestrator.js +9 -1
  33. package/src/presets/remote_control/specs.js +80 -61
  34. package/src/receipt.js +6 -3
  35. 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
- const payload = jsonrpcError(null, -32000, `stream error: ${String(error)}`);
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
- write(output, jsonrpcError(null, -32000, `input line exceeds maximum length (${MAX_LINE_LENGTH} bytes)`));
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
- write(output, jsonrpcError(null, -32000, `input line exceeds maximum length (${MAX_LINE_LENGTH} bytes)`));
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
- const response = this.handleRawLine(raw);
55
- if (response !== null) {
56
- if (typeof response.then === "function") {
57
- response.then((resolved) => { if (resolved !== null)
58
- write(output, resolved); }, (error) => { write(output, jsonrpcError(requestId, -32000, String(error))); });
59
- }
60
- else {
61
- write(output, response);
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
- stream.write(`${JSON.stringify(payload)}\n`);
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.js
5
- // Description: AUTO-GENERATED compiled from config.ts. Runtime configuration for the remote-control preset.
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
- import { DEFAULT_VERSION, asString } from "./config.js";
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
- if (typeof raw === "boolean") {
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()}_${Math.floor(Math.random() * 1e4).toString(16)}`;
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 { McpToolProvider, McpToolResult } from "../../mcp/server.js";
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":