@node-i3x/mcp 0.11.0 → 0.12.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.
package/dist/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { createRequire as createRequire2 } from "module";
5
+
3
6
  // src/utils/similarity.ts
4
7
  function computeSimilarity(a, b) {
5
8
  const s1 = a.toLowerCase().trim();
@@ -27,25 +30,50 @@ function computeSimilarity(a, b) {
27
30
  const maxLen = Math.max(m, n);
28
31
  return Math.max(0, 1 - distance / maxLen);
29
32
  }
30
- function rankSuggestions(query, candidates, topN = 5, minScore = 0.4) {
31
- return candidates.map((candidate) => ({
32
- candidate,
33
- score: computeSimilarity(query, candidate)
34
- })).filter((item) => item.score >= minScore).sort((a, b) => b.score - a.score).slice(0, topN).map((item) => item.candidate);
33
+ var MAX_FUZZY_QUERY_LENGTH = 128;
34
+ function boundedSimilarity(query, candidate, minScore) {
35
+ const q = query.toLowerCase().trim();
36
+ const c = candidate.toLowerCase().trim();
37
+ if (!q || !c) return 0;
38
+ if (q.includes(c) || c.includes(q)) return computeSimilarity(q, c);
39
+ const lengthBound = 1 - Math.abs(q.length - c.length) / Math.max(q.length, c.length);
40
+ return lengthBound < minScore ? 0 : computeSimilarity(q, c);
41
+ }
42
+ function rankObjectSuggestions(query, objects, topN = 5, minScore = 0.4) {
43
+ if (query.trim().length > MAX_FUZZY_QUERY_LENGTH) return [];
44
+ return objects.map(({ elementId, displayName }) => ({
45
+ elementId,
46
+ displayName,
47
+ score: Math.max(
48
+ boundedSimilarity(query, elementId, minScore),
49
+ displayName ? boundedSimilarity(query, displayName, minScore) : 0
50
+ )
51
+ })).filter((item) => item.score >= minScore).sort((a, b) => b.score - a.score).slice(0, topN).map(({ elementId, displayName }) => ({ elementId, displayName }));
35
52
  }
36
53
 
37
54
  // src/bindings/rest-binding.ts
38
- var RestClientBinding = class {
55
+ var OBJECT_CACHE_TTL_MS = 6e4;
56
+ var RestClientBinding = class _RestClientBinding {
39
57
  baseUrl;
40
58
  headers;
41
59
  requestTimeoutMs;
42
- constructor(baseUrl = "http://localhost:3000", token, timeoutMs = 15e3) {
60
+ fetchImpl;
61
+ token;
62
+ objectCache;
63
+ /**
64
+ * @param fetchImpl - replaces the global fetch, e.g. to route requests into an
65
+ * in-process server (see InProcessConnectorBinding).
66
+ */
67
+ constructor(baseUrl = "http://localhost:3000", token, timeoutMs = 15e3, fetchImpl) {
43
68
  let cleanUrl = baseUrl.trim().replace(/\/+$/, "");
44
69
  if (!cleanUrl.startsWith("http://") && !cleanUrl.startsWith("https://")) {
45
70
  cleanUrl = `http://${cleanUrl}`;
46
71
  }
72
+ cleanUrl = cleanUrl.replace(/\/v1$/i, "");
47
73
  this.baseUrl = cleanUrl;
48
74
  this.requestTimeoutMs = timeoutMs;
75
+ this.fetchImpl = fetchImpl;
76
+ this.token = token;
49
77
  this.headers = {
50
78
  "Content-Type": "application/json",
51
79
  Accept: "application/json"
@@ -54,11 +82,27 @@ var RestClientBinding = class {
54
82
  this.headers.Authorization = `Bearer ${token}`;
55
83
  }
56
84
  }
85
+ /** Normalized base URL, without a trailing /v1. */
86
+ getBaseUrl() {
87
+ return this.baseUrl;
88
+ }
89
+ get hasToken() {
90
+ return Boolean(this.token);
91
+ }
92
+ /**
93
+ * A binding to `baseUrl`. Without `token`, the current token is kept when the URL
94
+ * is this binding's own (re-connecting must not silently drop the configured token).
95
+ */
96
+ connectTo(baseUrl, token) {
97
+ const next = new _RestClientBinding(baseUrl, token, this.requestTimeoutMs);
98
+ if (token !== void 0 || next.baseUrl !== this.baseUrl || !this.token) return next;
99
+ return new _RestClientBinding(baseUrl, this.token, this.requestTimeoutMs);
100
+ }
57
101
  async request(path, options = {}) {
58
102
  const url = `${this.baseUrl}${path}`;
59
103
  const signal = options.signal || AbortSignal.timeout(this.requestTimeoutMs);
60
104
  try {
61
- const response = await fetch(url, {
105
+ const response = await (this.fetchImpl ?? fetch)(url, {
62
106
  ...options,
63
107
  signal,
64
108
  headers: {
@@ -81,98 +125,134 @@ var RestClientBinding = class {
81
125
  throw err;
82
126
  }
83
127
  }
128
+ /** Unwrap a `{ success, result }` envelope; a `success: false` envelope becomes an error. */
129
+ async get(path) {
130
+ const res = await this.request(path);
131
+ if (res?.success !== true || !("result" in res)) {
132
+ throw new Error(`i3X request ${path} failed: ${describeFailure(res)}`);
133
+ }
134
+ return res.result;
135
+ }
136
+ /**
137
+ * POST a bulk request and return its `results` items, one per requested elementId.
138
+ * `success` is false on the envelope as soon as one item failed, so items are
139
+ * returned as they are; only a body without `results` is an error here.
140
+ */
141
+ async postBulk(path, body) {
142
+ const res = await this.request(path, {
143
+ method: "POST",
144
+ body: JSON.stringify(body)
145
+ });
146
+ if (!Array.isArray(res?.results)) {
147
+ throw new Error(`i3X request ${path} failed: ${describeFailure(res)}`);
148
+ }
149
+ return res.results;
150
+ }
151
+ /** Bulk items as per-element results: a failed item keeps its elementId and error. */
152
+ async postBulkPerElement(path, elementIds, body) {
153
+ const items = await this.postBulk(path, { elementIds, ...body });
154
+ return items.map((item, idx) => {
155
+ const elementId = item.elementId ?? elementIds[idx] ?? `element_${idx}`;
156
+ if (!item.success) return { elementId, error: describeFailure(item) };
157
+ if (item.result == null) return { elementId, error: "Server returned no result" };
158
+ return { elementId, result: item.result };
159
+ });
160
+ }
161
+ /** The single item of a one-element bulk request. */
162
+ async postBulkOne(path, body) {
163
+ const [item] = await this.postBulk(path, body);
164
+ if (!item) throw new Error(`i3X request ${path} returned no result`);
165
+ return item;
166
+ }
84
167
  async getInfo() {
85
- return this.request("/v1/info");
168
+ return this.get("/v1/info");
86
169
  }
87
170
  async getNamespaces() {
88
- const res = await this.request("/v1/namespaces");
89
- return res.namespaces || [];
171
+ return this.get("/v1/namespaces");
90
172
  }
91
173
  async getObjectTypes(namespaceUri) {
92
174
  const query = namespaceUri ? `?namespaceUri=${encodeURIComponent(namespaceUri)}` : "";
93
- const res = await this.request(
94
- `/v1/objecttypes${query}`
95
- );
96
- return res.objectTypes || [];
175
+ return this.get(`/v1/objecttypes${query}`);
97
176
  }
98
177
  async getRelationshipTypes() {
99
- const res = await this.request(
100
- "/v1/relationshiptypes"
101
- );
102
- return res.relationshipTypes || [];
178
+ return this.get("/v1/relationshiptypes");
103
179
  }
104
180
  async getObjects(options) {
105
181
  const params = new URLSearchParams();
106
182
  if (options?.rootOnly) params.set("root", "true");
107
183
  if (options?.typeElementId) params.set("typeElementId", options.typeElementId);
108
184
  const query = params.toString() ? `?${params.toString()}` : "";
109
- const res = await this.request(
110
- `/v1/objects${query}`
111
- );
112
- return res.objects || [];
185
+ return this.get(`/v1/objects${query}`);
113
186
  }
187
+ /** `null` only when the server reports the element as not found (404). */
114
188
  async getObjectById(elementId) {
115
- try {
116
- const res = await this.request(
117
- "/v1/objects/list",
118
- {
119
- method: "POST",
120
- body: JSON.stringify({ elementIds: [elementId] })
121
- }
122
- );
123
- if (res.objects && res.objects.length > 0) {
124
- return res.objects[0] ?? null;
125
- }
126
- return null;
127
- } catch {
128
- return null;
129
- }
189
+ const path = "/v1/objects/list";
190
+ const item = await this.postBulkOne(path, {
191
+ elementIds: [elementId]
192
+ });
193
+ if (item.success) return item.result ?? null;
194
+ if (item.responseDetail?.status === 404) return null;
195
+ throw new Error(
196
+ `i3X request ${path} failed for '${elementId}': ${describeFailure(item)}`
197
+ );
130
198
  }
131
199
  async getRelatedObjects(elementId, relationshipType) {
132
- try {
133
- const body = { elementId };
134
- if (relationshipType) body.relationshipType = relationshipType;
135
- const res = await this.request("/v1/objects/related", {
136
- method: "POST",
137
- body: JSON.stringify(body)
138
- });
139
- return (res.relatedObjects || []).map((r) => r.targetObject);
140
- } catch {
141
- return [];
200
+ const path = "/v1/objects/related";
201
+ const body = {
202
+ elementIds: [elementId]
203
+ };
204
+ if (relationshipType) body.relationshipType = relationshipType;
205
+ const item = await this.postBulkOne(path, body);
206
+ if (!item.success) {
207
+ throw new Error(
208
+ `i3X request ${path} failed for '${elementId}': ${describeFailure(item)}`
209
+ );
142
210
  }
211
+ return (item.result ?? []).filter(
212
+ (r) => !relationshipType || r.sourceRelationship === relationshipType
213
+ );
143
214
  }
144
- async getObjectValues(elementIds, maxDepth = 1) {
145
- const res = await this.request(
146
- "/v1/objects/value",
147
- {
148
- method: "POST",
149
- body: JSON.stringify({ elementIds, maxDepth })
150
- }
215
+ async getRelatedObjectsBulk(elementIds) {
216
+ return this.postBulkPerElement(
217
+ "/v1/objects/related",
218
+ elementIds,
219
+ {}
151
220
  );
152
- return res.values || [];
221
+ }
222
+ async getObjectValues(elementIds, maxDepth = 1) {
223
+ return this.postBulkPerElement("/v1/objects/value", elementIds, {
224
+ maxDepth
225
+ });
153
226
  }
154
227
  async getHistoricalValues(elementIds, startTime, endTime, maxDepth = 1) {
155
- const res = await this.request(
228
+ return this.postBulkPerElement(
156
229
  "/v1/objects/history",
157
- {
158
- method: "POST",
159
- body: JSON.stringify({ elementIds, startTime, endTime, maxDepth })
160
- }
230
+ elementIds,
231
+ { startTime, endTime, maxDepth }
161
232
  );
162
- return res.history || [];
163
233
  }
164
- async findSuggestions(unknownId) {
165
- try {
166
- const allObjects = await this.getObjects();
167
- const candidates = allObjects.map((o) => o.elementId);
168
- return rankSuggestions(unknownId, candidates, 5, 0.4);
169
- } catch {
170
- return [];
234
+ /** Closest objects by elementId or displayName; a failure to list objects propagates. */
235
+ async findSuggestions(query) {
236
+ if (query.trim().length > MAX_FUZZY_QUERY_LENGTH) return [];
237
+ const now = Date.now();
238
+ if (!this.objectCache || now - this.objectCache.at > OBJECT_CACHE_TTL_MS) {
239
+ const objects = this.getObjects();
240
+ this.objectCache = { at: now, objects };
241
+ objects.catch(() => {
242
+ if (this.objectCache?.objects === objects) this.objectCache = void 0;
243
+ });
171
244
  }
245
+ return rankObjectSuggestions(query, await this.objectCache.objects);
172
246
  }
173
247
  };
248
+ function describeFailure(res) {
249
+ const d = res?.responseDetail;
250
+ if (d) return [d.status, d.title, d.detail].filter(Boolean).join(" ");
251
+ return `unexpected response ${JSON.stringify(res)?.slice(0, 200)}`;
252
+ }
174
253
 
175
254
  // src/server.ts
255
+ import { createRequire } from "module";
176
256
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
177
257
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
178
258
  import {
@@ -188,12 +268,18 @@ var connectSchema = z.object({
188
268
  serverUrl: z.string().describe(
189
269
  "The base URL of the i3X Gateway (e.g. 'http://192.168.1.50:8000' or 'https://i3x-portal.sterfive.io/instance/SESSION_ID')"
190
270
  ),
191
- authToken: z.string().optional().describe("Optional Bearer authentication token for secured endpoints"),
192
- verifyConnection: z.boolean().optional().default(true).describe("Whether to immediately verify connectivity by querying server /v1/info")
271
+ authToken: z.string().optional().describe(
272
+ "Optional Bearer authentication token. Omitted when re-connecting to the current URL: the current token is kept."
273
+ ),
274
+ verifyConnection: z.boolean().optional().default(true).describe(
275
+ "Whether to verify connectivity and credentials before switching (reads /v1/info and /v1/namespaces)"
276
+ )
193
277
  });
194
- async function handleConnect(args2, onBindingSwitch) {
278
+ async function handleConnect(args2, onBindingSwitch, current) {
195
279
  const { serverUrl, authToken: authToken2, verifyConnection } = args2;
196
- const newBinding = new RestClientBinding(serverUrl, authToken2);
280
+ const newBinding = current instanceof RestClientBinding ? current.connectTo(serverUrl, authToken2) : new RestClientBinding(serverUrl, authToken2);
281
+ const tokenKept = authToken2 === void 0 && newBinding.hasToken;
282
+ const url = newBinding.getBaseUrl();
197
283
  let serverInfo;
198
284
  if (verifyConnection) {
199
285
  try {
@@ -201,19 +287,39 @@ async function handleConnect(args2, onBindingSwitch) {
201
287
  } catch (err) {
202
288
  return {
203
289
  status: "connection_failed",
204
- serverUrl,
290
+ serverUrl: url,
205
291
  error: err.message,
206
- tip: "Please check that the server URL is reachable and the authentication token (if required) is valid."
292
+ tip: "Please check that the server URL is reachable."
293
+ };
294
+ }
295
+ try {
296
+ await newBinding.getNamespaces();
297
+ } catch (err) {
298
+ const message = err.message;
299
+ if (/^HTTP 40[13]\b/.test(message)) {
300
+ return {
301
+ status: "auth_failed",
302
+ serverUrl: url,
303
+ error: message,
304
+ tip: "The server rejected the credentials. Pass a valid authToken; the previous connection is still active."
305
+ };
306
+ }
307
+ return {
308
+ status: "connection_failed",
309
+ serverUrl: url,
310
+ error: message,
311
+ tip: "The server answered /v1/info but not /v1/namespaces; the previous connection is still active."
207
312
  };
208
313
  }
209
314
  }
210
315
  onBindingSwitch(newBinding);
211
316
  return {
212
317
  status: "connected",
213
- serverUrl,
214
- authenticated: Boolean(authToken2),
318
+ serverUrl: url,
319
+ authenticated: newBinding.hasToken,
320
+ ...tokenKept ? { note: "No authToken given: kept the token already in use for this URL." } : {},
215
321
  serverInfo: serverInfo || "Connection established without verification.",
216
- message: `Successfully connected to ${serverUrl}. All subsequent i3X tool calls will query this endpoint.`
322
+ message: `Successfully connected to ${url}. All subsequent i3X tool calls will query this endpoint.`
217
323
  };
218
324
  }
219
325
 
@@ -265,14 +371,30 @@ async function handleDiscoverTypes(binding2, args2) {
265
371
 
266
372
  // src/tools/explore-hierarchy.ts
267
373
  import { z as z3 } from "zod";
374
+
375
+ // src/utils/relationships.ts
376
+ var PARENT_RELATIONSHIPS = /* @__PURE__ */ new Set(["IsComponentOf", "ComponentOf", "HasParent"]);
377
+ function isParentRelationship(sourceRelationship) {
378
+ return PARENT_RELATIONSHIPS.has(sourceRelationship);
379
+ }
380
+
381
+ // src/tools/explore-hierarchy.ts
382
+ var RELATED_BATCH_SIZE = 100;
383
+ var RELATED_CONCURRENCY = 4;
268
384
  var exploreHierarchySchema = z3.object({
269
385
  rootElementId: z3.string().optional().describe(
270
386
  "Optional specific root asset elementId. If omitted, explores all root assets."
271
387
  ),
272
- maxDepth: z3.number().optional().default(2).describe("Maximum depth to traverse child components (1 to 5, default: 2)")
388
+ maxDepth: z3.number().int().min(0).optional().default(2).describe(
389
+ "Levels to show: 1 = roots only, 2 = roots and their children (default), 0 = unlimited (bounded by maxNodes)"
390
+ ),
391
+ maxNodes: z3.number().int().min(1).max(5e3).optional().default(500).describe(
392
+ "Node budget for the whole tree (default 500, max 5000); the tree is marked truncated when reached"
393
+ )
273
394
  });
274
395
  async function handleExploreHierarchy(binding2, args2) {
275
- const maxDepth = Math.min(Math.max(args2.maxDepth || 2, 1), 5);
396
+ const maxDepth = args2.maxDepth ?? 2;
397
+ const maxNodes = args2.maxNodes ?? 500;
276
398
  let roots;
277
399
  if (args2.rootElementId) {
278
400
  const root = await binding2.getObjectById(args2.rootElementId);
@@ -287,34 +409,93 @@ async function handleExploreHierarchy(binding2, args2) {
287
409
  } else {
288
410
  roots = await binding2.getObjects({ rootOnly: true });
289
411
  }
290
- async function buildNode(asset, currentDepth, visited) {
291
- const node = {
292
- elementId: asset.elementId,
293
- displayName: asset.displayName || asset.elementId,
294
- typeElementId: asset.typeElementId
295
- };
296
- visited.add(asset.elementId);
297
- if (currentDepth < maxDepth) {
298
- const children = await binding2.getRelatedObjects(asset.elementId);
299
- const unvisitedChildren = children.filter((c) => !visited.has(c.elementId));
300
- if (unvisitedChildren.length > 0) {
301
- node.children = await Promise.all(
302
- unvisitedChildren.map(
303
- (child) => buildNode(child, currentDepth + 1, new Set(visited))
304
- )
305
- );
412
+ const toNode = (asset) => ({
413
+ elementId: asset.elementId,
414
+ displayName: asset.displayName || asset.elementId,
415
+ typeElementId: asset.typeElementId
416
+ });
417
+ let nodeCount = 0;
418
+ let truncated = false;
419
+ const expanded = /* @__PURE__ */ new Set();
420
+ const tree = [];
421
+ for (const root of roots) {
422
+ if (nodeCount >= maxNodes) {
423
+ truncated = true;
424
+ break;
425
+ }
426
+ tree.push(toNode(root));
427
+ nodeCount++;
428
+ }
429
+ let level = tree;
430
+ for (let depth = 1; level.length > 0 && (maxDepth === 0 || depth < maxDepth); depth++) {
431
+ const toExpand = [];
432
+ for (const node of level) {
433
+ if (expanded.has(node.elementId)) node.repeated = true;
434
+ else {
435
+ expanded.add(node.elementId);
436
+ toExpand.push(node);
306
437
  }
307
438
  }
308
- return node;
439
+ const related = await readRelatedInBatches(
440
+ binding2,
441
+ toExpand.map((n) => n.elementId)
442
+ );
443
+ const next = [];
444
+ toExpand.forEach((node, idx) => {
445
+ const entry = related[idx];
446
+ if (entry?.error) {
447
+ node.error = entry.error;
448
+ return;
449
+ }
450
+ const children = (entry?.result ?? []).filter((r) => !isParentRelationship(r.sourceRelationship)).map((r) => r.object);
451
+ if (children.length === 0) return;
452
+ if (nodeCount + children.length > maxNodes) {
453
+ truncated = true;
454
+ node.truncated = true;
455
+ return;
456
+ }
457
+ node.children = children.map(toNode);
458
+ nodeCount += children.length;
459
+ next.push(...node.children);
460
+ });
461
+ level = next;
309
462
  }
310
- const hierarchyTree = await Promise.all(
311
- roots.map((root) => buildNode(root, 1, /* @__PURE__ */ new Set()))
312
- );
313
463
  return {
314
464
  totalRoots: roots.length,
315
- traversalDepth: maxDepth,
316
- plantTopology: hierarchyTree
465
+ traversalDepth: maxDepth === 0 ? "unlimited" : maxDepth,
466
+ nodeCount,
467
+ ...truncated ? {
468
+ truncated: {
469
+ maxNodes,
470
+ hint: "Node budget reached; nodes marked truncated were not expanded. Use rootElementId on one of them, a smaller maxDepth, or a larger maxNodes."
471
+ }
472
+ } : {},
473
+ plantTopology: tree
474
+ };
475
+ }
476
+ async function readRelatedInBatches(binding2, elementIds) {
477
+ const batches = [];
478
+ for (let i = 0; i < elementIds.length; i += RELATED_BATCH_SIZE) {
479
+ batches.push(elementIds.slice(i, i + RELATED_BATCH_SIZE));
480
+ }
481
+ const results = new Array(batches.length);
482
+ let nextBatch = 0;
483
+ const worker = async () => {
484
+ while (nextBatch < batches.length) {
485
+ const idx = nextBatch++;
486
+ const batch = batches[idx];
487
+ try {
488
+ results[idx] = await binding2.getRelatedObjectsBulk(batch);
489
+ } catch (err) {
490
+ const error = err instanceof Error ? err.message : String(err);
491
+ results[idx] = batch.map((elementId) => ({ elementId, error }));
492
+ }
493
+ }
317
494
  };
495
+ await Promise.all(
496
+ Array.from({ length: Math.min(RELATED_CONCURRENCY, batches.length) }, worker)
497
+ );
498
+ return results.flat();
318
499
  }
319
500
 
320
501
  // src/tools/inspect-asset.ts
@@ -322,10 +503,13 @@ import { z as z4 } from "zod";
322
503
  var inspectAssetSchema = z4.object({
323
504
  elementId: z4.string().describe("The elementId of the asset to inspect (e.g. 'Pump_101', 'Boiler_101')"),
324
505
  includeValues: z4.boolean().optional().default(true).describe("Whether to include live VQT (Value, Quality, Timestamp) telemetry values"),
325
- includeSchema: z4.boolean().optional().default(true).describe("Whether to include the ObjectType JSON Schema definition")
506
+ includeSchema: z4.boolean().optional().default(true).describe("Whether to include the ObjectType JSON Schema definition"),
507
+ maxDepth: z4.number().int().min(0).optional().default(2).describe(
508
+ "Value read depth through components: 1 = the asset only (an Object usually has no value of its own), 2 = with its components (default), 0 = unlimited"
509
+ )
326
510
  });
327
511
  async function handleInspectAsset(binding2, args2) {
328
- const { elementId, includeValues, includeSchema } = args2;
512
+ const { elementId, includeValues, includeSchema, maxDepth } = args2;
329
513
  const asset = await binding2.getObjectById(elementId);
330
514
  if (!asset) {
331
515
  const suggestions = await binding2.findSuggestions(elementId);
@@ -340,25 +524,35 @@ async function handleInspectAsset(binding2, args2) {
340
524
  displayName: asset.displayName || asset.elementId,
341
525
  description: asset.metadata?.description || void 0,
342
526
  typeElementId: asset.typeElementId,
527
+ parentId: asset.parentId ?? null,
343
528
  isRoot: !asset.parentId
344
529
  };
530
+ const warnings = [];
345
531
  const relatedPromise = binding2.getRelatedObjects(elementId);
346
532
  const schemaPromise = includeSchema && asset.typeElementId ? binding2.getObjectTypes() : Promise.resolve(null);
347
- const valuesPromise = includeValues ? binding2.getObjectValues([elementId]) : Promise.resolve(null);
533
+ const valuesPromise = includeValues ? binding2.getObjectValues([elementId], maxDepth) : Promise.resolve(null);
348
534
  const [relatedResult, schemaResult, valuesResult] = await Promise.allSettled([
349
535
  relatedPromise,
350
536
  schemaPromise,
351
537
  valuesPromise
352
538
  ]);
353
- if (relatedResult.status === "fulfilled" && Array.isArray(relatedResult.value) && relatedResult.value.length > 0) {
354
- result.subComponents = relatedResult.value.map((r) => ({
355
- elementId: r.elementId,
356
- displayName: r.displayName || r.elementId,
357
- typeElementId: r.typeElementId
358
- }));
539
+ if (relatedResult.status === "fulfilled") {
540
+ const subComponents = relatedResult.value.filter(
541
+ (r) => !isParentRelationship(r.sourceRelationship)
542
+ );
543
+ if (subComponents.length > 0) {
544
+ result.subComponents = subComponents.map((r) => ({
545
+ elementId: r.object.elementId,
546
+ displayName: r.object.displayName || r.object.elementId,
547
+ typeElementId: r.object.typeElementId,
548
+ relationship: r.sourceRelationship
549
+ }));
550
+ }
551
+ } else {
552
+ warnings.push(`Sub-components unavailable: ${errorMessage(relatedResult.reason)}`);
359
553
  }
360
- if (schemaResult.status === "fulfilled" && Array.isArray(schemaResult.value)) {
361
- const targetType = schemaResult.value.find(
554
+ if (schemaResult.status === "fulfilled") {
555
+ const targetType = schemaResult.value?.find(
362
556
  (t) => t.elementId === asset.typeElementId
363
557
  );
364
558
  if (targetType) {
@@ -368,101 +562,177 @@ async function handleInspectAsset(binding2, args2) {
368
562
  schema: targetType.schema
369
563
  };
370
564
  }
565
+ } else {
566
+ warnings.push(`Type schema unavailable: ${errorMessage(schemaResult.reason)}`);
371
567
  }
372
- if (valuesResult.status === "fulfilled" && Array.isArray(valuesResult.value) && valuesResult.value.length > 0) {
373
- result.currentValues = valuesResult.value[0];
374
- } else if (valuesResult.status === "rejected") {
375
- result.currentValuesWarning = "Telemetry data temporarily unavailable";
568
+ if (valuesResult.status === "fulfilled") {
569
+ const entry = valuesResult.value?.[0];
570
+ if (entry?.result) result.currentValues = entry.result;
571
+ else if (entry?.error) warnings.push(`Telemetry unavailable: ${entry.error}`);
572
+ } else {
573
+ warnings.push(`Telemetry unavailable: ${errorMessage(valuesResult.reason)}`);
376
574
  }
575
+ if (warnings.length > 0) result.warnings = warnings;
377
576
  return result;
378
577
  }
578
+ function errorMessage(reason) {
579
+ return reason instanceof Error ? reason.message : String(reason);
580
+ }
379
581
 
380
582
  // src/tools/monitor-events.ts
583
+ import { z as z6 } from "zod";
584
+
585
+ // src/utils/element-ids.ts
381
586
  import { z as z5 } from "zod";
382
- var monitorEventsSchema = z5.object({
383
- elementIds: z5.array(z5.string()).min(1).describe("Array of asset elementIds to monitor (e.g. ['Boiler_101', 'Pump_101'])"),
384
- condition: z5.string().optional().describe(
385
- "Optional monitoring alert condition filter (e.g. 'temperature > 85 || quality != Good')"
587
+ var MAX_ELEMENT_IDS = 100;
588
+ function elementIdsSchema(description) {
589
+ return z5.array(z5.string()).min(1).transform((ids) => [...new Set(ids)]).refine((ids) => ids.length <= MAX_ELEMENT_IDS, {
590
+ message: `At most ${MAX_ELEMENT_IDS} distinct elementIds per call.`
591
+ }).describe(`${description}; duplicates are ignored, at most ${MAX_ELEMENT_IDS}`);
592
+ }
593
+
594
+ // src/tools/monitor-events.ts
595
+ var MAX_LISTED_ALERTS = 50;
596
+ var monitorEventsSchema = z6.object({
597
+ elementIds: elementIdsSchema(
598
+ "Array of asset elementIds to check (e.g. ['Boiler_101', 'Pump_101'])"
599
+ ),
600
+ maxDepth: z6.number().int().min(0).optional().default(2).describe(
601
+ "Read depth through components: 1 = the element only, 2 = with its components (default), 0 = unlimited"
386
602
  )
387
603
  });
388
604
  async function handleMonitorEvents(binding2, args2) {
389
- const { elementIds, condition } = args2;
390
- const currentValues = await binding2.getObjectValues(elementIds);
605
+ const { elementIds, maxDepth } = args2;
606
+ const snapshot = await binding2.getObjectValues(elementIds, maxDepth);
607
+ const alerts = [];
608
+ for (const { elementId, result, error } of snapshot) {
609
+ if (error) {
610
+ alerts.push({ elementId, error });
611
+ continue;
612
+ }
613
+ if (result && !isGood(result.quality)) {
614
+ alerts.push({ elementId, quality: result.quality });
615
+ }
616
+ for (const [componentId, vqt] of Object.entries(result?.components ?? {})) {
617
+ if (!isGood(vqt.quality))
618
+ alerts.push({ elementId: componentId, quality: vqt.quality });
619
+ }
620
+ }
621
+ const summary = {
622
+ bad: alerts.filter((a) => a.quality && /^bad/i.test(a.quality)).length,
623
+ uncertain: alerts.filter((a) => a.quality && /^uncertain/i.test(a.quality)).length,
624
+ readErrors: alerts.filter((a) => a.error).length
625
+ };
391
626
  return {
392
- status: "monitoring_active",
393
- monitoredAssets: elementIds,
394
- alertCondition: condition || "All value or quality changes",
395
- initialSnapshot: currentValues,
396
- instructions: "Subscription stream registered. The MCP server will emit notification events on matching VQT telemetry updates or threshold breaches."
627
+ status: "snapshot",
628
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
629
+ summary,
630
+ alerts: alerts.slice(0, MAX_LISTED_ALERTS),
631
+ ...alerts.length > MAX_LISTED_ALERTS ? { alertsOmitted: alerts.length - MAX_LISTED_ALERTS } : {},
632
+ snapshot,
633
+ note: "Point-in-time read; no subscription is created. Call again to re-check."
397
634
  };
398
635
  }
636
+ function isGood(quality) {
637
+ return String(quality).toLowerCase().startsWith("good");
638
+ }
399
639
 
400
640
  // src/tools/query-telemetry.ts
401
- import { z as z6 } from "zod";
402
- var queryTelemetrySchema = z6.object({
403
- elementIds: z6.array(z6.string()).min(1).describe("Array of asset elementIds to query (e.g. ['Pump_101', 'Boiler_101'])"),
404
- mode: z6.enum(["current", "history"]).optional().default("current").describe(
641
+ import { z as z7 } from "zod";
642
+ var queryTelemetrySchema = z7.object({
643
+ elementIds: elementIdsSchema(
644
+ "Array of asset elementIds to query (e.g. ['Pump_101', 'Boiler_101'])"
645
+ ),
646
+ mode: z7.enum(["current", "history"]).optional().default("current").describe(
405
647
  "Query mode: 'current' for live VQT telemetry, 'history' for time-series trend analysis"
406
648
  ),
407
- startTime: z6.string().optional().describe(
408
- "ISO 8601 start time or relative time string (e.g. '-2h', '-30m', '-1d') for history mode"
649
+ startTime: z7.string().optional().describe(
650
+ "ISO 8601 start time or relative time string (e.g. '-2h', '-30m', '-1d') for history mode (default: -1h)"
651
+ ),
652
+ endTime: z7.string().optional().describe(
653
+ "ISO 8601 end time or relative time string (e.g. '-10m') for history mode (default: now)"
409
654
  ),
410
- endTime: z6.string().optional().describe("ISO 8601 end time (defaults to now) for history mode"),
411
- aggregateStats: z6.boolean().optional().default(true).describe(
655
+ maxDepth: z7.number().int().min(0).optional().default(2).describe(
656
+ "Read depth through components: 1 = the element only, 2 = with its components (default), 0 = unlimited"
657
+ ),
658
+ aggregateStats: z7.boolean().optional().default(true).describe(
412
659
  "Whether to pre-compute min/max/mean statistical summaries for numeric sensor streams"
413
660
  )
414
661
  });
415
- function parseStartTime(input) {
416
- if (!input) {
417
- const d = /* @__PURE__ */ new Date();
418
- d.setHours(d.getHours() - 1);
419
- return d.toISOString();
420
- }
421
- const match = input.match(/^-(\d+)([smhdw])$/i);
662
+ var RELATIVE_TIME = /^-(\d+)([smhdw])$/i;
663
+ var UNIT_MS = {
664
+ s: 1e3,
665
+ m: 6e4,
666
+ h: 36e5,
667
+ d: 864e5,
668
+ w: 6048e5
669
+ };
670
+ function parseTime(input, fallback, name) {
671
+ if (!input) return fallback.toISOString();
672
+ const match = input.match(RELATIVE_TIME);
422
673
  if (match) {
423
- const amount = parseInt(match[1], 10);
424
- const unit = match[2].toLowerCase();
425
- const d = /* @__PURE__ */ new Date();
426
- switch (unit) {
427
- case "s":
428
- d.setSeconds(d.getSeconds() - amount);
429
- break;
430
- case "m":
431
- d.setMinutes(d.getMinutes() - amount);
432
- break;
433
- case "h":
434
- d.setHours(d.getHours() - amount);
435
- break;
436
- case "d":
437
- d.setDate(d.getDate() - amount);
438
- break;
439
- case "w":
440
- d.setDate(d.getDate() - amount * 7);
441
- break;
442
- }
443
- return d.toISOString();
674
+ const amount = Number.parseInt(match[1], 10);
675
+ return new Date(
676
+ Date.now() - amount * UNIT_MS[match[2].toLowerCase()]
677
+ ).toISOString();
444
678
  }
445
679
  const parsed = new Date(input);
446
680
  if (Number.isNaN(parsed.getTime())) {
447
- const defaultDate = /* @__PURE__ */ new Date();
448
- defaultDate.setHours(defaultDate.getHours() - 1);
449
- return defaultDate.toISOString();
681
+ throw new Error(
682
+ `Invalid ${name} '${input}': expected ISO 8601 (e.g. '2026-10-25T10:00:00Z') or a relative time (e.g. '-2h').`
683
+ );
450
684
  }
451
685
  return parsed.toISOString();
452
686
  }
687
+ function describeDetail(d) {
688
+ return [d.status, d.title, d.detail].filter(Boolean).join(" ");
689
+ }
690
+ function isBadQuality(quality) {
691
+ return quality != null && !String(quality).toLowerCase().startsWith("good");
692
+ }
693
+ function seriesStatistics(values) {
694
+ const numericValues = [];
695
+ let badQualityCount = 0;
696
+ for (const v of values) {
697
+ if (isBadQuality(v.quality)) badQualityCount++;
698
+ if (typeof v.value === "number" && !Number.isNaN(v.value))
699
+ numericValues.push(v.value);
700
+ }
701
+ if (numericValues.length === 0) return { count: values.length, badQualityCount };
702
+ const sum = numericValues.reduce((a, b) => a + b, 0);
703
+ return {
704
+ min: Math.min(...numericValues),
705
+ max: Math.max(...numericValues),
706
+ mean: Math.round(sum / numericValues.length * 100) / 100,
707
+ count: numericValues.length,
708
+ badQualityCount
709
+ };
710
+ }
453
711
  async function handleQueryTelemetry(binding2, args2) {
454
- const { elementIds, mode, aggregateStats } = args2;
712
+ const { elementIds, mode, aggregateStats, maxDepth } = args2;
455
713
  if (mode === "current") {
456
- const values = await binding2.getObjectValues(elementIds);
714
+ const values = await binding2.getObjectValues(elementIds, maxDepth);
457
715
  return {
458
716
  mode: "current",
459
717
  telemetry: values
460
718
  };
461
719
  }
462
- const startTime = parseStartTime(args2.startTime);
463
- const endTimeDate = args2.endTime ? new Date(args2.endTime) : /* @__PURE__ */ new Date();
464
- const endTime = Number.isNaN(endTimeDate.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : endTimeDate.toISOString();
465
- const history = await binding2.getHistoricalValues(elementIds, startTime, endTime);
720
+ const now = /* @__PURE__ */ new Date();
721
+ const startTime = parseTime(
722
+ args2.startTime,
723
+ new Date(now.getTime() - UNIT_MS.h),
724
+ "startTime"
725
+ );
726
+ const endTime = parseTime(args2.endTime, now, "endTime");
727
+ if (startTime > endTime) {
728
+ throw new Error(`startTime ${startTime} is after endTime ${endTime}.`);
729
+ }
730
+ const history = await binding2.getHistoricalValues(
731
+ elementIds,
732
+ startTime,
733
+ endTime,
734
+ maxDepth
735
+ );
466
736
  if (!aggregateStats) {
467
737
  return {
468
738
  mode: "history",
@@ -470,41 +740,27 @@ async function handleQueryTelemetry(binding2, args2) {
470
740
  history
471
741
  };
472
742
  }
473
- const reports = history.map((record, idx) => {
474
- const elementId = elementIds[idx] || `element_${idx}`;
743
+ const reports = history.map(({ elementId, result, error }) => {
744
+ if (error || !result) return { elementId, error: error ?? "No history returned" };
475
745
  const report = {
476
746
  elementId,
477
- timeRange: { startTime, endTime },
478
- dataPoints: record.values || []
747
+ dataPoints: result.values ?? [],
748
+ statistics: seriesStatistics(result.values ?? [])
479
749
  };
480
- const numericValues = [];
481
- let badQualityCount = 0;
482
- for (const v of record.values || []) {
483
- if (v.quality && String(v.quality).toUpperCase() !== "GOOD") {
484
- badQualityCount++;
485
- }
486
- if (typeof v.value === "number" && !Number.isNaN(v.value)) {
487
- numericValues.push(v.value);
750
+ if (result.components) {
751
+ const entries = Object.entries(result.components);
752
+ report.components = Object.fromEntries(
753
+ entries.map(([componentId, { values, responseDetail }]) => [
754
+ componentId,
755
+ // A failed component read is not an empty time range: report why.
756
+ responseDetail ? { error: describeDetail(responseDetail) } : { dataPoints: values ?? [], statistics: seriesStatistics(values ?? []) }
757
+ ])
758
+ );
759
+ const failed = entries.filter(([, c]) => c.responseDetail);
760
+ if (failed.length > 0 && failed.length === entries.length && !result.values?.length) {
761
+ report.note = `No component history could be read (${describeDetail(failed[0][1].responseDetail)}): the server keeps no history for this asset's variables.`;
488
762
  }
489
763
  }
490
- if (numericValues.length > 0) {
491
- const min = Math.min(...numericValues);
492
- const max = Math.max(...numericValues);
493
- const sum = numericValues.reduce((a, b) => a + b, 0);
494
- const mean = Math.round(sum / numericValues.length * 100) / 100;
495
- report.statistics = {
496
- min,
497
- max,
498
- mean,
499
- count: numericValues.length,
500
- badQualityCount
501
- };
502
- } else {
503
- report.statistics = {
504
- count: (record.values || []).length,
505
- badQualityCount
506
- };
507
- }
508
764
  return report;
509
765
  });
510
766
  return {
@@ -514,16 +770,41 @@ async function handleQueryTelemetry(binding2, args2) {
514
770
  };
515
771
  }
516
772
 
773
+ // src/utils/output-limit.ts
774
+ var DEFAULT_MAX_RESULT_CHARS = 1e5;
775
+ var NARROWING_HINTS = {
776
+ i3x_explore_plant_hierarchy: "pass rootElementId, or lower maxDepth or maxNodes",
777
+ i3x_inspect_asset: "lower maxDepth, or set includeSchema to false",
778
+ i3x_query_telemetry: "pass fewer elementIds, lower maxDepth, or shorten the time range",
779
+ i3x_discover_types_and_schemas: "pass typeElementId or namespaceUri",
780
+ i3x_monitor_asset_events: "pass fewer elementIds or lower maxDepth"
781
+ };
782
+ function serializeToolResult(tool, result, maxChars) {
783
+ const text = JSON.stringify(result, null, 2);
784
+ if (text.length <= maxChars) return text;
785
+ const hint = NARROWING_HINTS[tool] ?? "narrow the query";
786
+ return `${text.slice(0, maxChars)}
787
+
788
+ [Result truncated: showing the first ${maxChars} of ${text.length} characters, so the JSON above is incomplete. To see everything, ${hint}.]`;
789
+ }
790
+
517
791
  // src/server.ts
792
+ var { version } = createRequire(import.meta.url)("../package.json");
518
793
  var NodeI3xMcpServer = class {
519
794
  server;
520
795
  binding;
521
- constructor(binding2) {
796
+ maxResultChars;
797
+ /**
798
+ * @param options.maxResultChars - cap on a serialized tool result (default
799
+ * I3X_MCP_MAX_RESULT_CHARS, else 100 000 characters).
800
+ */
801
+ constructor(binding2, options = {}) {
522
802
  this.binding = binding2 || new RestClientBinding();
803
+ this.maxResultChars = options.maxResultChars ?? (Number(process.env.I3X_MCP_MAX_RESULT_CHARS) || DEFAULT_MAX_RESULT_CHARS);
523
804
  this.server = new Server(
524
805
  {
525
806
  name: "@node-i3x/mcp",
526
- version: "0.10.0"
807
+ version
527
808
  },
528
809
  {
529
810
  capabilities: {
@@ -555,11 +836,11 @@ var NodeI3xMcpServer = class {
555
836
  },
556
837
  authToken: {
557
838
  type: "string",
558
- description: "Optional Bearer authentication token for secured endpoints"
839
+ description: "Optional Bearer authentication token. Omit it to keep the current token when re-connecting to the same URL."
559
840
  },
560
841
  verifyConnection: {
561
842
  type: "boolean",
562
- description: "Whether to verify connectivity by querying server /v1/info (default: true)"
843
+ description: "Whether to verify connectivity and credentials before switching (default: true); a rejected token returns status auth_failed"
563
844
  }
564
845
  },
565
846
  required: ["serverUrl"]
@@ -582,6 +863,10 @@ var NodeI3xMcpServer = class {
582
863
  includeSchema: {
583
864
  type: "boolean",
584
865
  description: "Whether to include the ObjectType JSON Schema definition (default: true)"
866
+ },
867
+ maxDepth: {
868
+ type: "number",
869
+ description: "Value read depth through components: 1 = the element only, 2 = with its components (default), 0 = unlimited"
585
870
  }
586
871
  },
587
872
  required: ["elementId"]
@@ -599,7 +884,11 @@ var NodeI3xMcpServer = class {
599
884
  },
600
885
  maxDepth: {
601
886
  type: "number",
602
- description: "Maximum depth to traverse child components (1 to 5, default: 2)"
887
+ description: "Levels to show: 1 = roots only, 2 = roots and their children (default), 0 = unlimited (bounded by maxNodes)"
888
+ },
889
+ maxNodes: {
890
+ type: "number",
891
+ description: "Node budget for the whole tree (default 500, max 5000); the result is marked truncated when reached"
603
892
  }
604
893
  }
605
894
  }
@@ -613,7 +902,7 @@ var NodeI3xMcpServer = class {
613
902
  elementIds: {
614
903
  type: "array",
615
904
  items: { type: "string" },
616
- description: "Array of asset elementIds to query (e.g. ['Pump_101', 'Boiler_101'])"
905
+ description: "Array of asset elementIds to query (e.g. ['Pump_101', 'Boiler_101']); duplicates are ignored, at most 100"
617
906
  },
618
907
  mode: {
619
908
  type: "string",
@@ -626,7 +915,11 @@ var NodeI3xMcpServer = class {
626
915
  },
627
916
  endTime: {
628
917
  type: "string",
629
- description: "ISO 8601 end time (defaults to now) for history mode"
918
+ description: "ISO 8601 or relative duration (e.g. '-10m') end time (defaults to now) for history mode"
919
+ },
920
+ maxDepth: {
921
+ type: "number",
922
+ description: "Value read depth through components: 1 = the element only, 2 = with its components (default), 0 = unlimited"
630
923
  },
631
924
  aggregateStats: {
632
925
  type: "boolean",
@@ -655,18 +948,18 @@ var NodeI3xMcpServer = class {
655
948
  },
656
949
  {
657
950
  name: "i3x_monitor_asset_events",
658
- description: "Create an i3X subscription stream to monitor real-time VQT value changes or threshold alert conditions.",
951
+ description: "One-shot health check of assets: current VQT values plus an alert list of every series whose quality is not Good. Does not subscribe; call again to re-check.",
659
952
  inputSchema: {
660
953
  type: "object",
661
954
  properties: {
662
955
  elementIds: {
663
956
  type: "array",
664
957
  items: { type: "string" },
665
- description: "Array of asset elementIds to monitor"
958
+ description: "Array of asset elementIds to check; duplicates are ignored, at most 100"
666
959
  },
667
- condition: {
668
- type: "string",
669
- description: "Optional alert condition filter (e.g. 'temperature > 85 || quality != Good')"
960
+ maxDepth: {
961
+ type: "number",
962
+ description: "Value read depth through components: 1 = the element only, 2 = with its components (default), 0 = unlimited"
670
963
  }
671
964
  },
672
965
  required: ["elementIds"]
@@ -681,9 +974,13 @@ var NodeI3xMcpServer = class {
681
974
  switch (name) {
682
975
  case "i3x_connect": {
683
976
  const parsed = connectSchema.parse(rawArgs);
684
- result = await handleConnect(parsed, (newBinding) => {
685
- this.binding = newBinding;
686
- });
977
+ result = await handleConnect(
978
+ parsed,
979
+ (newBinding) => {
980
+ this.binding = newBinding;
981
+ },
982
+ this.binding
983
+ );
687
984
  break;
688
985
  }
689
986
  case "i3x_inspect_asset": {
@@ -718,7 +1015,7 @@ var NodeI3xMcpServer = class {
718
1015
  content: [
719
1016
  {
720
1017
  type: "text",
721
- text: JSON.stringify(result, null, 2)
1018
+ text: serializeToolResult(name, result, this.maxResultChars)
722
1019
  }
723
1020
  ]
724
1021
  };
@@ -813,7 +1110,8 @@ Environment Variables:
813
1110
  process.exit(0);
814
1111
  }
815
1112
  if (args.includes("--version") || args.includes("-v")) {
816
- console.error("0.10.0");
1113
+ const { version: version2 } = createRequire2(import.meta.url)("../package.json");
1114
+ console.log(version2);
817
1115
  process.exit(0);
818
1116
  }
819
1117
  function getArgValue(flag) {