UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

183 lines (182 loc) 8.45 kB
/** * MCP Output Normalizer * * Single responsibility: intercept a raw MCP `CallToolResult`, measure it, * and apply the configured strategy so that oversized payloads never reach * caches, Redis, or LLM context windows raw. * * Two strategies: * - "inline" Pass through unchanged. The full payload enters the LLM * context as-is. Emit a warning above warnBytes. * - "externalize" Write the full payload to the ArtifactStore, return a * compact surrogate with a head/tail preview and an artifact * ID. The model uses `retrieve_context` with that ID to read * the full output on demand, with offset/limit pagination. * * The surrogate result is shaped as an MCP `CallToolResult` so it passes * transparently through any downstream code that expects that format. * A `_meta` extension carries the artifact ID for structured extraction in * `redisConversationMemoryManager`. * * @module mcp/mcpOutputNormalizer */ import { generateToolOutputPreview } from "../context/toolOutputLimits.js"; import { logger } from "../utils/logger.js"; import { withTimeout } from "../utils/errorHandling.js"; // Re-export so callers can import everything from one place // --------------------------------------------------------------------------- // Public constants // --------------------------------------------------------------------------- /** Default byte ceiling above which externalize fires (100 KB). */ export const DEFAULT_MAX_MCP_OUTPUT_BYTES = 100 * 1024; /** Default byte threshold for emitting a warning while still inline (50 KB). */ export const DEFAULT_WARN_MCP_OUTPUT_BYTES = 50 * 1024; /** Metadata key embedded in surrogate `_meta` and used by memory manager. */ export const NEUROLINK_ARTIFACT_ID_KEY = "neurolinkArtifactId"; // --------------------------------------------------------------------------- // McpOutputNormalizer // --------------------------------------------------------------------------- /** * Stateless normalizer (state lives in the injected ArtifactStore). * * Construct once per NeuroLink instance and set via * `ToolDiscoveryService.setOutputNormalizer()`. */ export class McpOutputNormalizer { config; artifactStore; constructor(config, artifactStore) { this.config = config; this.artifactStore = artifactStore; } /** * Measure `callResult`, apply strategy if oversized, return normalized output. * * Never throws: on any internal failure the raw result is returned unchanged * with a warning log so tool execution is never broken by the normalizer. */ async normalize(callResult, context) { const serialized = serialize(callResult); const originalBytes = Buffer.byteLength(serialized, "utf-8"); // Fast path: below warn threshold — always inline, no logging if (originalBytes <= this.config.warnBytes) { return { result: callResult, isExternalized: false, originalBytes }; } // Between warn and max: emit a warning but keep inline regardless of strategy if (originalBytes <= this.config.maxBytes) { logger.warn(`[McpOutputNormalizer] Large MCP output from "${context.toolName}" on ` + `"${context.serverId}" (${formatBytes(originalBytes)}). ` + `Approaching limit of ${formatBytes(this.config.maxBytes)}.`, { toolName: context.toolName, serverId: context.serverId, originalBytes, }); return { result: callResult, isExternalized: false, originalBytes }; } // Above max — apply strategy logger.warn(`[McpOutputNormalizer] MCP output from "${context.toolName}" on ` + `"${context.serverId}" exceeds limit ` + `(${formatBytes(originalBytes)} > ${formatBytes(this.config.maxBytes)}). ` + `Applying strategy "${this.config.strategy}".`, { toolName: context.toolName, serverId: context.serverId, originalBytes }); if (this.config.strategy === "inline") { // Caller explicitly opted in to inline regardless of size return { result: callResult, isExternalized: false, originalBytes }; } // strategy === "externalize" if (!this.artifactStore) { // Misconfiguration: externalize was chosen but no store was provided. // Pass through inline so execution is never broken, but log loudly. logger.error(`[McpOutputNormalizer] strategy="externalize" but no ArtifactStore ` + `configured — passing through raw result for "${context.toolName}". ` + `Set mcp.outputLimits.strategy="externalize" and ensure the NeuroLink ` + `constructor creates a LocalTempArtifactStore.`); return { result: callResult, isExternalized: false, originalBytes }; } let ref; try { ref = await withTimeout(this.artifactStore.store(serialized, { toolName: context.toolName, serverId: context.serverId, sessionId: context.sessionId, sizeBytes: originalBytes, contentType: isJsonLike(callResult) ? "json" : "text", }), 10_000, new Error(`ArtifactStore.store() timed out for "${context.toolName}"`)); } catch (err) { // Storage failure or timeout — pass through inline so the call doesn't break. logger.error(`[McpOutputNormalizer] ArtifactStore.store() failed for ` + `"${context.toolName}": ${err instanceof Error ? err.message : String(err)} ` + `— passing through raw result.`); return { result: callResult, isExternalized: false, originalBytes }; } // Generate a compact head/tail preview for the surrogate. // Cap at warnBytes so the surrogate itself is always well within limits. const { preview } = generateToolOutputPreview(serialized, { maxBytes: Math.min(this.config.warnBytes, DEFAULT_WARN_MCP_OUTPUT_BYTES), }); return { result: buildSurrogate(preview, ref.id, context, originalBytes), isExternalized: true, artifactId: ref.id, originalBytes, }; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Build a compact MCP-shaped surrogate the LLM receives instead of the * raw oversized payload. * * Shape mirrors `CallToolResult` so downstream code that inspects * `result.content` keeps working unchanged. * `_meta` carries the artifact ID for structured extraction in * `redisConversationMemoryManager`. */ function buildSurrogate(preview, artifactId, context, originalBytes) { const text = `[MCP Tool Output — ${context.toolName} | ${context.serverId}]\n` + `Original size: ${formatBytes(originalBytes)} | ` + `Externalized — use retrieve_context with artifactId="${artifactId}" ` + `to read the full output (supports offset + limit pagination)\n` + `\n--- Preview (head + tail) ---\n` + preview + `\n--- End Preview ---\n` + `[${NEUROLINK_ARTIFACT_ID_KEY}=${artifactId}]`; return { content: [{ type: "text", text }], _meta: { [NEUROLINK_ARTIFACT_ID_KEY]: artifactId, originalBytes, toolName: context.toolName, serverId: context.serverId, }, }; } function serialize(value) { if (typeof value === "string") { return value; } try { // Compact JSON keeps byte measurement accurate and size enforcement honest. // Pretty-printing with null, 2 inflates every object by ~30–50 % and would // shift the externalization threshold relative to what the LLM actually // receives if the payload is ever inlined. return JSON.stringify(value); } catch { return String(value); } } function isJsonLike(value) { return typeof value === "object" && value !== null; } function formatBytes(bytes) { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }