@juspay/neurolink 9.51.2 → 9.51.4

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 (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/artifacts/artifactStore.d.ts +56 -0
  3. package/dist/artifacts/artifactStore.js +143 -0
  4. package/dist/browser/neurolink.min.js +314 -303
  5. package/dist/cli/commands/mcp.d.ts +6 -0
  6. package/dist/cli/commands/mcp.js +128 -86
  7. package/dist/cli/factories/commandFactory.js +3 -2
  8. package/dist/cli/utils/typewriter.d.ts +18 -0
  9. package/dist/cli/utils/typewriter.js +42 -0
  10. package/dist/core/modules/ToolsManager.d.ts +10 -0
  11. package/dist/core/modules/ToolsManager.js +108 -32
  12. package/dist/core/redisConversationMemoryManager.js +20 -0
  13. package/dist/lib/artifacts/artifactStore.d.ts +56 -0
  14. package/dist/lib/artifacts/artifactStore.js +144 -0
  15. package/dist/lib/core/modules/ToolsManager.d.ts +10 -0
  16. package/dist/lib/core/modules/ToolsManager.js +108 -32
  17. package/dist/lib/core/redisConversationMemoryManager.js +20 -0
  18. package/dist/lib/mcp/externalServerManager.d.ts +6 -0
  19. package/dist/lib/mcp/externalServerManager.js +9 -0
  20. package/dist/lib/mcp/mcpOutputNormalizer.d.ts +49 -0
  21. package/dist/lib/mcp/mcpOutputNormalizer.js +182 -0
  22. package/dist/lib/mcp/toolDiscoveryService.d.ts +10 -0
  23. package/dist/lib/mcp/toolDiscoveryService.js +32 -1
  24. package/dist/lib/memory/memoryRetrievalTools.d.ts +64 -9
  25. package/dist/lib/memory/memoryRetrievalTools.js +77 -9
  26. package/dist/lib/neurolink.d.ts +2 -0
  27. package/dist/lib/neurolink.js +97 -88
  28. package/dist/lib/session/globalSessionState.js +44 -1
  29. package/dist/lib/types/artifactTypes.d.ts +63 -0
  30. package/dist/lib/types/artifactTypes.js +11 -0
  31. package/dist/lib/types/configTypes.d.ts +34 -2
  32. package/dist/lib/types/conversation.d.ts +7 -0
  33. package/dist/lib/types/index.d.ts +2 -0
  34. package/dist/lib/types/mcpOutputTypes.d.ts +40 -0
  35. package/dist/lib/types/mcpOutputTypes.js +9 -0
  36. package/dist/mcp/externalServerManager.d.ts +6 -0
  37. package/dist/mcp/externalServerManager.js +9 -0
  38. package/dist/mcp/mcpOutputNormalizer.d.ts +49 -0
  39. package/dist/mcp/mcpOutputNormalizer.js +181 -0
  40. package/dist/mcp/toolDiscoveryService.d.ts +10 -0
  41. package/dist/mcp/toolDiscoveryService.js +32 -1
  42. package/dist/memory/memoryRetrievalTools.d.ts +64 -9
  43. package/dist/memory/memoryRetrievalTools.js +77 -9
  44. package/dist/neurolink.d.ts +2 -0
  45. package/dist/neurolink.js +97 -88
  46. package/dist/session/globalSessionState.js +44 -1
  47. package/dist/types/artifactTypes.d.ts +63 -0
  48. package/dist/types/artifactTypes.js +10 -0
  49. package/dist/types/configTypes.d.ts +34 -2
  50. package/dist/types/conversation.d.ts +7 -0
  51. package/dist/types/index.d.ts +2 -0
  52. package/dist/types/mcpOutputTypes.d.ts +40 -0
  53. package/dist/types/mcpOutputTypes.js +8 -0
  54. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Artifact Store
3
+ *
4
+ * Pluggable storage for externalized MCP tool outputs.
5
+ *
6
+ * When `mcp.outputLimits.strategy = "externalize"` the full tool payload is
7
+ * written here instead of being sent inline to the LLM. The model receives a
8
+ * compact surrogate with a preview and an artifact ID. The full payload can be
9
+ * retrieved on demand via the `retrieve_context` tool.
10
+ *
11
+ * Architecture:
12
+ * ArtifactStore (interface) — canonical types in src/lib/types/artifactTypes.ts
13
+ * LocalTempArtifactStore — single-process, filesystem-backed implementation
14
+ *
15
+ * Distributed backends (S3, Redis blobs) can be added later by implementing
16
+ * ArtifactStore from types/artifactTypes.ts.
17
+ *
18
+ * @module artifacts/artifactStore
19
+ */
20
+ import { randomUUID } from "node:crypto";
21
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { logger } from "../utils/logger.js";
25
+ // ---------------------------------------------------------------------------
26
+ // LocalTempArtifactStore
27
+ // ---------------------------------------------------------------------------
28
+ /** Characters used for the quick preview embedded in surrogate results. */
29
+ const DEFAULT_PREVIEW_CHARS = 500;
30
+ /**
31
+ * Filesystem-backed artifact store using the OS temp directory.
32
+ *
33
+ * Files are written with mode 0o600 (owner read/write only).
34
+ * An in-memory index tracks metadata without a separate index file.
35
+ *
36
+ * Suitable for:
37
+ * - CLI usage
38
+ * - Single-process SDK deployments
39
+ * - Multi-process deployments where each process manages its own artifacts
40
+ * (artifacts created in one process are not visible to others)
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const store = new LocalTempArtifactStore();
45
+ * const ref = await store.store(largeJson, {
46
+ * toolName: "list_files",
47
+ * serverId: "filesystem-server",
48
+ * sizeBytes: Buffer.byteLength(largeJson),
49
+ * contentType: "json",
50
+ * });
51
+ * // Later, via retrieve_context:
52
+ * const full = await store.retrieve(ref.id);
53
+ * ```
54
+ */
55
+ export class LocalTempArtifactStore {
56
+ dir;
57
+ index = new Map();
58
+ constructor(dir) {
59
+ this.dir = dir ?? join(tmpdir(), "neurolink-artifacts");
60
+ }
61
+ generatePreview(payload) {
62
+ if (payload.length <= DEFAULT_PREVIEW_CHARS) {
63
+ return payload;
64
+ }
65
+ return `${payload.slice(0, DEFAULT_PREVIEW_CHARS)}…`;
66
+ }
67
+ async store(payload, meta) {
68
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
69
+ const id = randomUUID();
70
+ const ext = meta.contentType === "json" ? ".json" : ".txt";
71
+ const filePath = join(this.dir, `${id}${ext}`);
72
+ await writeFile(filePath, payload, { encoding: "utf-8", mode: 0o600 });
73
+ const fullMeta = {
74
+ ...meta,
75
+ createdAt: Date.now(),
76
+ path: filePath,
77
+ };
78
+ this.index.set(id, fullMeta);
79
+ logger.debug(`[ArtifactStore] Stored artifact ${id} for tool "${meta.toolName}" ` +
80
+ `(${formatBytes(meta.sizeBytes)})`);
81
+ return {
82
+ id,
83
+ preview: this.generatePreview(payload),
84
+ sizeBytes: meta.sizeBytes,
85
+ meta: { ...meta, createdAt: fullMeta.createdAt },
86
+ };
87
+ }
88
+ async retrieve(id) {
89
+ const entry = this.index.get(id);
90
+ if (!entry) {
91
+ logger.debug(`[ArtifactStore] Artifact ${id} not in index`);
92
+ return null;
93
+ }
94
+ try {
95
+ const content = await readFile(entry.path, "utf-8");
96
+ logger.debug(`[ArtifactStore] Retrieved artifact ${id} (${formatBytes(entry.sizeBytes)})`);
97
+ return content;
98
+ }
99
+ catch (err) {
100
+ logger.warn(`[ArtifactStore] Failed to read artifact ${id}: ${err instanceof Error ? err.message : String(err)}`);
101
+ return null;
102
+ }
103
+ }
104
+ async delete(id) {
105
+ const entry = this.index.get(id);
106
+ if (!entry) {
107
+ return;
108
+ }
109
+ try {
110
+ await rm(entry.path, { force: true });
111
+ }
112
+ catch {
113
+ // Suppress — file may already be gone
114
+ }
115
+ this.index.delete(id);
116
+ }
117
+ async cleanup(olderThanMs) {
118
+ const cutoff = Date.now() - olderThanMs;
119
+ let count = 0;
120
+ for (const [id, entry] of this.index.entries()) {
121
+ if (entry.createdAt < cutoff) {
122
+ await this.delete(id);
123
+ count++;
124
+ }
125
+ }
126
+ if (count > 0) {
127
+ logger.debug(`[ArtifactStore] Cleaned up ${count} expired artifact(s)`);
128
+ }
129
+ return count;
130
+ }
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // Helpers
134
+ // ---------------------------------------------------------------------------
135
+ function formatBytes(bytes) {
136
+ if (bytes < 1024) {
137
+ return `${bytes} B`;
138
+ }
139
+ if (bytes < 1024 * 1024) {
140
+ return `${(bytes / 1024).toFixed(1)} KB`;
141
+ }
142
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
143
+ }
144
+ //# sourceMappingURL=artifactStore.js.map
@@ -31,6 +31,16 @@ export declare class ToolsManager {
31
31
  protected sessionId?: string;
32
32
  protected userId?: string;
33
33
  constructor(providerName: AIProviderName, directTools: Record<string, unknown>, neurolink?: NeuroLink | undefined, utilities?: ToolUtilities | undefined);
34
+ /**
35
+ * BZ-666: Wrap tool execute with output truncation to prevent
36
+ * context overflow when large results flow into the AI SDK accumulator.
37
+ */
38
+ private wrapExecuteWithTruncation;
39
+ /**
40
+ * BZ-666: Apply generateToolOutputPreview to tool results to prevent
41
+ * context overflow when large results flow into the AI SDK accumulator.
42
+ */
43
+ private truncateToolResult;
34
44
  /**
35
45
  * Set session context for MCP tools
36
46
  */
@@ -22,6 +22,7 @@ import { SpanStatusCode } from "@opentelemetry/api";
22
22
  import { logger } from "../../utils/logger.js";
23
23
  import { getKeyCount } from "../../utils/transformationUtils.js";
24
24
  import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js";
25
+ import { generateToolOutputPreview } from "../../context/toolOutputLimits.js";
25
26
  /**
26
27
  * ToolsManager class - Handles all tool management operations
27
28
  */
@@ -44,6 +45,79 @@ export class ToolsManager {
44
45
  this.utilities = utilities;
45
46
  this.mcpTools = {};
46
47
  }
48
+ /**
49
+ * BZ-666: Wrap tool execute with output truncation to prevent
50
+ * context overflow when large results flow into the AI SDK accumulator.
51
+ */
52
+ wrapExecuteWithTruncation(toolName, originalExecute) {
53
+ return async (params) => {
54
+ const result = await originalExecute(params);
55
+ return this.truncateToolResult(toolName, result);
56
+ };
57
+ }
58
+ /**
59
+ * BZ-666: Apply generateToolOutputPreview to tool results to prevent
60
+ * context overflow when large results flow into the AI SDK accumulator.
61
+ */
62
+ truncateToolResult(toolName, result) {
63
+ if (result === null || result === undefined) {
64
+ return result;
65
+ }
66
+ // Handle string results directly
67
+ if (typeof result === "string") {
68
+ const { preview, truncated, originalSize } = generateToolOutputPreview(result);
69
+ if (truncated) {
70
+ logger.debug(`[ToolsManager] Truncated '${toolName}' string output: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
71
+ }
72
+ return truncated ? preview : result;
73
+ }
74
+ // Handle object results (e.g. readFile returns { content, ... })
75
+ if (typeof result === "object") {
76
+ const obj = result;
77
+ let nextObj = null;
78
+ // Truncate "content" if present and oversized
79
+ if (typeof obj.content === "string") {
80
+ const { preview, truncated, originalSize } = generateToolOutputPreview(obj.content);
81
+ if (truncated) {
82
+ logger.debug(`[ToolsManager] Truncated '${toolName}' content field: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
83
+ nextObj = { ...(nextObj ?? obj), content: preview };
84
+ }
85
+ }
86
+ // Truncate "data" if present and oversized — both fields can coexist
87
+ if (typeof obj.data === "string") {
88
+ const { preview, truncated, originalSize } = generateToolOutputPreview(obj.data);
89
+ if (truncated) {
90
+ logger.debug(`[ToolsManager] Truncated '${toolName}' data field: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
91
+ nextObj = { ...(nextObj ?? obj), data: preview };
92
+ }
93
+ }
94
+ if (nextObj) {
95
+ return nextObj;
96
+ }
97
+ // For other objects, check if their JSON serialization is too large.
98
+ // Use UTF-8 byte length, not string length, to match the 50KB budget.
99
+ try {
100
+ const jsonStr = JSON.stringify(result);
101
+ if (Buffer.byteLength(jsonStr, "utf-8") > 51_200) {
102
+ const { preview, truncated, originalSize } = generateToolOutputPreview(jsonStr);
103
+ if (truncated) {
104
+ logger.debug(`[ToolsManager] Truncated '${toolName}' JSON output: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
105
+ // Preserve object shape so callers reading structured fields don't
106
+ // get a type surprise. Attach the preview under a sentinel field.
107
+ return {
108
+ _truncated: true,
109
+ _originalSize: originalSize,
110
+ _preview: preview,
111
+ };
112
+ }
113
+ }
114
+ }
115
+ catch {
116
+ // JSON serialization failed — return as-is
117
+ }
118
+ }
119
+ return result;
120
+ }
47
121
  /**
48
122
  * Set session context for MCP tools
49
123
  */
@@ -179,14 +253,15 @@ export class ToolsManager {
179
253
  typeof directTool === "object" &&
180
254
  "execute" in directTool) {
181
255
  const originalExecute = directTool.execute;
182
- // Create a new tool with wrapped execute function
256
+ // Create a new tool with wrapped execute function (BZ-666/BZ-664 guards applied)
257
+ const guardedExecute = this.wrapExecuteWithTruncation(toolName, originalExecute);
183
258
  tools[toolName] = {
184
259
  ...directTool,
185
260
  execute: async (params) => {
186
261
  const startTime = Date.now();
187
262
  this.emitToolEvent("tool:start", toolName, { input: params });
188
263
  try {
189
- const result = await originalExecute(params);
264
+ const result = await guardedExecute(params);
190
265
  this.emitToolEvent("tool:end", toolName, {
191
266
  result,
192
267
  success: true,
@@ -228,6 +303,12 @@ export class ToolsManager {
228
303
  if (toolInfo && typeof toolInfo.execute === "function") {
229
304
  const tool = await this.createCustomToolFromDefinition(toolName, toolInfo);
230
305
  if (tool && !tools[toolName]) {
306
+ // BZ-666/BZ-664: Wrap custom tool execute with guards
307
+ const origExec = tool.execute;
308
+ if (origExec) {
309
+ const guarded = this.wrapExecuteWithTruncation(toolName, origExec);
310
+ tool.execute = guarded;
311
+ }
231
312
  tools[toolName] = tool;
232
313
  }
233
314
  }
@@ -444,47 +525,42 @@ export class ToolsManager {
444
525
  ? this.utilities.createPermissiveZodSchema()
445
526
  : z.object({});
446
527
  }
528
+ // BZ-666/BZ-664: Wrap the raw MCP execute with guards before event wrapping
529
+ const rawExecute = async (params) => {
530
+ if (this.neurolink &&
531
+ typeof this.neurolink.executeExternalMCPTool === "function") {
532
+ return this.neurolink.executeExternalMCPTool(tool.serverId || "unknown", tool.name, params);
533
+ }
534
+ throw new Error(`Cannot execute external MCP tool: NeuroLink executeExternalMCPTool not available`);
535
+ };
536
+ const guardedExecute = this.wrapExecuteWithTruncation(tool.name, rawExecute);
447
537
  return createAISDKTool({
448
538
  description: tool.description || `External MCP tool ${tool.name}`,
449
539
  inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
450
540
  execute: async (params) => {
451
541
  const startTime = Date.now();
452
542
  this.emitToolEvent("tool:start", tool.name, { input: params });
453
- // Execute via NeuroLink's direct tool execution
454
- if (this.neurolink &&
455
- typeof this.neurolink.executeExternalMCPTool === "function") {
456
- try {
457
- const result = await this.neurolink.executeExternalMCPTool(tool.serverId || "unknown", tool.name, params);
458
- this.emitToolEvent("tool:end", tool.name, {
459
- result,
460
- success: true,
461
- responseTime: Date.now() - startTime,
462
- });
463
- return result;
464
- }
465
- catch (mcpError) {
466
- const errorMsg = mcpError instanceof Error ? mcpError.message : String(mcpError);
467
- this.emitToolEvent("tool:end", tool.name, {
468
- error: errorMsg,
469
- success: false,
470
- responseTime: Date.now() - startTime,
471
- });
472
- logger.error(`External MCP tool failed: ${tool.name}`, {
473
- serverId: tool.serverId,
474
- error: errorMsg,
475
- });
476
- throw mcpError;
477
- }
543
+ try {
544
+ const result = await guardedExecute(params);
545
+ this.emitToolEvent("tool:end", tool.name, {
546
+ result,
547
+ success: true,
548
+ responseTime: Date.now() - startTime,
549
+ });
550
+ return result;
478
551
  }
479
- else {
480
- const error = `Cannot execute external MCP tool: NeuroLink executeExternalMCPTool not available`;
552
+ catch (mcpError) {
553
+ const errorMsg = mcpError instanceof Error ? mcpError.message : String(mcpError);
481
554
  this.emitToolEvent("tool:end", tool.name, {
482
- error,
555
+ error: errorMsg,
483
556
  success: false,
484
557
  responseTime: Date.now() - startTime,
485
558
  });
486
- logger.error(error);
487
- throw new Error(error);
559
+ logger.error(`External MCP tool failed: ${tool.name}`, {
560
+ serverId: tool.serverId,
561
+ error: errorMsg,
562
+ });
563
+ throw mcpError;
488
564
  }
489
565
  },
490
566
  });
@@ -7,6 +7,7 @@ import { tracers } from "../telemetry/tracers.js";
7
7
  import { randomUUID } from "crypto";
8
8
  import { MESSAGES_PER_TURN } from "../config/conversationMemory.js";
9
9
  import { generateToolOutputPreview } from "../context/toolOutputLimits.js";
10
+ import { NEUROLINK_ARTIFACT_ID_KEY } from "../mcp/mcpOutputNormalizer.js";
10
11
  import { SummarizationEngine } from "../context/summarizationEngine.js";
11
12
  import { NeuroLink } from "../neurolink.js";
12
13
  import { ConversationMemoryError } from "../types/conversation.js";
@@ -1320,11 +1321,30 @@ User message: "${userMessage}"`;
1320
1321
  maxBytes: this.config?.contextCompaction?.maxToolOutputBytes,
1321
1322
  maxLines: this.config?.contextCompaction?.maxToolOutputLines,
1322
1323
  });
1324
+ // Extract artifact ID if this result was externalized by McpOutputNormalizer.
1325
+ // The surrogate carries `_meta.neurolinkArtifactId` on the raw result object.
1326
+ let artifactId;
1327
+ try {
1328
+ const rawResult = toolResult.result;
1329
+ if (rawResult && typeof rawResult === "object") {
1330
+ const meta = rawResult._meta;
1331
+ if (meta && typeof meta === "object") {
1332
+ const idValue = meta[NEUROLINK_ARTIFACT_ID_KEY];
1333
+ if (typeof idValue === "string") {
1334
+ artifactId = idValue;
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ catch {
1340
+ // Ignore extraction errors — artifact ID is best-effort metadata
1341
+ }
1323
1342
  // Build metadata — only store preview when truncation occurred (no duplication)
1324
1343
  const metadata = {
1325
1344
  truncated,
1326
1345
  ...(truncated && { toolOutputPreview: preview }),
1327
1346
  ...(truncated && { originalSize }),
1347
+ ...(artifactId && { artifactId }),
1328
1348
  };
1329
1349
  // Build result — success/error metadata only, NOT the output data
1330
1350
  const result = {
@@ -27,6 +27,12 @@ export declare class ExternalServerManager extends EventEmitter {
27
27
  constructor(config?: ExternalMCPManagerConfig, options?: {
28
28
  enableMainRegistryIntegration?: boolean;
29
29
  });
30
+ /**
31
+ * Attach a McpOutputNormalizer to the underlying ToolDiscoveryService.
32
+ * All tool outputs will be measured and (if oversized) replaced with compact
33
+ * surrogates before being returned to callers.
34
+ */
35
+ setOutputNormalizer(normalizer: import("./mcpOutputNormalizer.js").McpOutputNormalizer): void;
30
36
  /**
31
37
  * Set HITL manager for human-in-the-loop safety mechanisms
32
38
  * @param hitlManager - HITL manager instance (optional, can be undefined to disable)
@@ -210,6 +210,15 @@ export class ExternalServerManager extends EventEmitter {
210
210
  process.on("SIGTERM", () => this.shutdown());
211
211
  process.on("beforeExit", () => this.shutdown());
212
212
  }
213
+ /**
214
+ * Attach a McpOutputNormalizer to the underlying ToolDiscoveryService.
215
+ * All tool outputs will be measured and (if oversized) replaced with compact
216
+ * surrogates before being returned to callers.
217
+ */
218
+ setOutputNormalizer(normalizer) {
219
+ this.toolDiscovery.setOutputNormalizer(normalizer);
220
+ mcpLogger.debug("[ExternalServerManager] MCP output normalizer attached to ToolDiscoveryService");
221
+ }
213
222
  /**
214
223
  * Set HITL manager for human-in-the-loop safety mechanisms
215
224
  * @param hitlManager - HITL manager instance (optional, can be undefined to disable)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * MCP Output Normalizer
3
+ *
4
+ * Single responsibility: intercept a raw MCP `CallToolResult`, measure it,
5
+ * and apply the configured strategy so that oversized payloads never reach
6
+ * caches, Redis, or LLM context windows raw.
7
+ *
8
+ * Two strategies:
9
+ * - "inline" Pass through unchanged. The full payload enters the LLM
10
+ * context as-is. Emit a warning above warnBytes.
11
+ * - "externalize" Write the full payload to the ArtifactStore, return a
12
+ * compact surrogate with a head/tail preview and an artifact
13
+ * ID. The model uses `retrieve_context` with that ID to read
14
+ * the full output on demand, with offset/limit pagination.
15
+ *
16
+ * The surrogate result is shaped as an MCP `CallToolResult` so it passes
17
+ * transparently through any downstream code that expects that format.
18
+ * A `_meta` extension carries the artifact ID for structured extraction in
19
+ * `redisConversationMemoryManager`.
20
+ *
21
+ * @module mcp/mcpOutputNormalizer
22
+ */
23
+ import type { ArtifactStore } from "../artifacts/artifactStore.js";
24
+ import type { McpOutputNormalizerConfig, McpOutputContext, NormalizedMcpOutput } from "../types/mcpOutputTypes.js";
25
+ export type { McpOutputStrategy, McpOutputNormalizerConfig, McpOutputContext, NormalizedMcpOutput, } from "../types/mcpOutputTypes.js";
26
+ /** Default byte ceiling above which externalize fires (100 KB). */
27
+ export declare const DEFAULT_MAX_MCP_OUTPUT_BYTES: number;
28
+ /** Default byte threshold for emitting a warning while still inline (50 KB). */
29
+ export declare const DEFAULT_WARN_MCP_OUTPUT_BYTES: number;
30
+ /** Metadata key embedded in surrogate `_meta` and used by memory manager. */
31
+ export declare const NEUROLINK_ARTIFACT_ID_KEY = "neurolinkArtifactId";
32
+ /**
33
+ * Stateless normalizer (state lives in the injected ArtifactStore).
34
+ *
35
+ * Construct once per NeuroLink instance and set via
36
+ * `ToolDiscoveryService.setOutputNormalizer()`.
37
+ */
38
+ export declare class McpOutputNormalizer {
39
+ private readonly config;
40
+ private readonly artifactStore?;
41
+ constructor(config: McpOutputNormalizerConfig, artifactStore?: ArtifactStore | undefined);
42
+ /**
43
+ * Measure `callResult`, apply strategy if oversized, return normalized output.
44
+ *
45
+ * Never throws: on any internal failure the raw result is returned unchanged
46
+ * with a warning log so tool execution is never broken by the normalizer.
47
+ */
48
+ normalize(callResult: unknown, context: McpOutputContext): Promise<NormalizedMcpOutput>;
49
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * MCP Output Normalizer
3
+ *
4
+ * Single responsibility: intercept a raw MCP `CallToolResult`, measure it,
5
+ * and apply the configured strategy so that oversized payloads never reach
6
+ * caches, Redis, or LLM context windows raw.
7
+ *
8
+ * Two strategies:
9
+ * - "inline" Pass through unchanged. The full payload enters the LLM
10
+ * context as-is. Emit a warning above warnBytes.
11
+ * - "externalize" Write the full payload to the ArtifactStore, return a
12
+ * compact surrogate with a head/tail preview and an artifact
13
+ * ID. The model uses `retrieve_context` with that ID to read
14
+ * the full output on demand, with offset/limit pagination.
15
+ *
16
+ * The surrogate result is shaped as an MCP `CallToolResult` so it passes
17
+ * transparently through any downstream code that expects that format.
18
+ * A `_meta` extension carries the artifact ID for structured extraction in
19
+ * `redisConversationMemoryManager`.
20
+ *
21
+ * @module mcp/mcpOutputNormalizer
22
+ */
23
+ import { generateToolOutputPreview } from "../context/toolOutputLimits.js";
24
+ import { logger } from "../utils/logger.js";
25
+ import { withTimeout } from "../utils/errorHandling.js";
26
+ // ---------------------------------------------------------------------------
27
+ // Public constants
28
+ // ---------------------------------------------------------------------------
29
+ /** Default byte ceiling above which externalize fires (100 KB). */
30
+ export const DEFAULT_MAX_MCP_OUTPUT_BYTES = 100 * 1024;
31
+ /** Default byte threshold for emitting a warning while still inline (50 KB). */
32
+ export const DEFAULT_WARN_MCP_OUTPUT_BYTES = 50 * 1024;
33
+ /** Metadata key embedded in surrogate `_meta` and used by memory manager. */
34
+ export const NEUROLINK_ARTIFACT_ID_KEY = "neurolinkArtifactId";
35
+ // ---------------------------------------------------------------------------
36
+ // McpOutputNormalizer
37
+ // ---------------------------------------------------------------------------
38
+ /**
39
+ * Stateless normalizer (state lives in the injected ArtifactStore).
40
+ *
41
+ * Construct once per NeuroLink instance and set via
42
+ * `ToolDiscoveryService.setOutputNormalizer()`.
43
+ */
44
+ export class McpOutputNormalizer {
45
+ config;
46
+ artifactStore;
47
+ constructor(config, artifactStore) {
48
+ this.config = config;
49
+ this.artifactStore = artifactStore;
50
+ }
51
+ /**
52
+ * Measure `callResult`, apply strategy if oversized, return normalized output.
53
+ *
54
+ * Never throws: on any internal failure the raw result is returned unchanged
55
+ * with a warning log so tool execution is never broken by the normalizer.
56
+ */
57
+ async normalize(callResult, context) {
58
+ const serialized = serialize(callResult);
59
+ const originalBytes = Buffer.byteLength(serialized, "utf-8");
60
+ // Fast path: below warn threshold — always inline, no logging
61
+ if (originalBytes <= this.config.warnBytes) {
62
+ return { result: callResult, isExternalized: false, originalBytes };
63
+ }
64
+ // Between warn and max: emit a warning but keep inline regardless of strategy
65
+ if (originalBytes <= this.config.maxBytes) {
66
+ logger.warn(`[McpOutputNormalizer] Large MCP output from "${context.toolName}" on ` +
67
+ `"${context.serverId}" (${formatBytes(originalBytes)}). ` +
68
+ `Approaching limit of ${formatBytes(this.config.maxBytes)}.`, {
69
+ toolName: context.toolName,
70
+ serverId: context.serverId,
71
+ originalBytes,
72
+ });
73
+ return { result: callResult, isExternalized: false, originalBytes };
74
+ }
75
+ // Above max — apply strategy
76
+ logger.warn(`[McpOutputNormalizer] MCP output from "${context.toolName}" on ` +
77
+ `"${context.serverId}" exceeds limit ` +
78
+ `(${formatBytes(originalBytes)} > ${formatBytes(this.config.maxBytes)}). ` +
79
+ `Applying strategy "${this.config.strategy}".`, { toolName: context.toolName, serverId: context.serverId, originalBytes });
80
+ if (this.config.strategy === "inline") {
81
+ // Caller explicitly opted in to inline regardless of size
82
+ return { result: callResult, isExternalized: false, originalBytes };
83
+ }
84
+ // strategy === "externalize"
85
+ if (!this.artifactStore) {
86
+ // Misconfiguration: externalize was chosen but no store was provided.
87
+ // Pass through inline so execution is never broken, but log loudly.
88
+ logger.error(`[McpOutputNormalizer] strategy="externalize" but no ArtifactStore ` +
89
+ `configured — passing through raw result for "${context.toolName}". ` +
90
+ `Set mcp.outputLimits.strategy="externalize" and ensure the NeuroLink ` +
91
+ `constructor creates a LocalTempArtifactStore.`);
92
+ return { result: callResult, isExternalized: false, originalBytes };
93
+ }
94
+ let ref;
95
+ try {
96
+ ref = await withTimeout(this.artifactStore.store(serialized, {
97
+ toolName: context.toolName,
98
+ serverId: context.serverId,
99
+ sessionId: context.sessionId,
100
+ sizeBytes: originalBytes,
101
+ contentType: isJsonLike(callResult) ? "json" : "text",
102
+ }), 10_000, new Error(`ArtifactStore.store() timed out for "${context.toolName}"`));
103
+ }
104
+ catch (err) {
105
+ // Storage failure or timeout — pass through inline so the call doesn't break.
106
+ logger.error(`[McpOutputNormalizer] ArtifactStore.store() failed for ` +
107
+ `"${context.toolName}": ${err instanceof Error ? err.message : String(err)} ` +
108
+ `— passing through raw result.`);
109
+ return { result: callResult, isExternalized: false, originalBytes };
110
+ }
111
+ // Generate a compact head/tail preview for the surrogate.
112
+ // Cap at warnBytes so the surrogate itself is always well within limits.
113
+ const { preview } = generateToolOutputPreview(serialized, {
114
+ maxBytes: Math.min(this.config.warnBytes, DEFAULT_WARN_MCP_OUTPUT_BYTES),
115
+ });
116
+ return {
117
+ result: buildSurrogate(preview, ref.id, context, originalBytes),
118
+ isExternalized: true,
119
+ artifactId: ref.id,
120
+ originalBytes,
121
+ };
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Helpers
126
+ // ---------------------------------------------------------------------------
127
+ /**
128
+ * Build a compact MCP-shaped surrogate the LLM receives instead of the
129
+ * raw oversized payload.
130
+ *
131
+ * Shape mirrors `CallToolResult` so downstream code that inspects
132
+ * `result.content` keeps working unchanged.
133
+ * `_meta` carries the artifact ID for structured extraction in
134
+ * `redisConversationMemoryManager`.
135
+ */
136
+ function buildSurrogate(preview, artifactId, context, originalBytes) {
137
+ const text = `[MCP Tool Output — ${context.toolName} | ${context.serverId}]\n` +
138
+ `Original size: ${formatBytes(originalBytes)} | ` +
139
+ `Externalized — use retrieve_context with artifactId="${artifactId}" ` +
140
+ `to read the full output (supports offset + limit pagination)\n` +
141
+ `\n--- Preview (head + tail) ---\n` +
142
+ preview +
143
+ `\n--- End Preview ---\n` +
144
+ `[${NEUROLINK_ARTIFACT_ID_KEY}=${artifactId}]`;
145
+ return {
146
+ content: [{ type: "text", text }],
147
+ _meta: {
148
+ [NEUROLINK_ARTIFACT_ID_KEY]: artifactId,
149
+ originalBytes,
150
+ toolName: context.toolName,
151
+ serverId: context.serverId,
152
+ },
153
+ };
154
+ }
155
+ function serialize(value) {
156
+ if (typeof value === "string") {
157
+ return value;
158
+ }
159
+ try {
160
+ // Compact JSON keeps byte measurement accurate and size enforcement honest.
161
+ // Pretty-printing with null, 2 inflates every object by ~30–50 % and would
162
+ // shift the externalization threshold relative to what the LLM actually
163
+ // receives if the payload is ever inlined.
164
+ return JSON.stringify(value);
165
+ }
166
+ catch {
167
+ return String(value);
168
+ }
169
+ }
170
+ function isJsonLike(value) {
171
+ return typeof value === "object" && value !== null;
172
+ }
173
+ function formatBytes(bytes) {
174
+ if (bytes < 1024) {
175
+ return `${bytes} B`;
176
+ }
177
+ if (bytes < 1024 * 1024) {
178
+ return `${(bytes / 1024).toFixed(1)} KB`;
179
+ }
180
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
181
+ }
182
+ //# sourceMappingURL=mcpOutputNormalizer.js.map