fix: align mcp transform pipeline with Anthropic Claude Code 2.1.128

Add ImageLimits type and plumb optional limits through the chain:
callMCPTool/callMCPToolWithUrlElicitationRetry -> processMCPResult ->
transformMCPResult -> transformResultContent -> maybeResizeAndDownsampleImageBuffer.
When provided, limits override the module-level defaults
(IMAGE_TARGET_RAW_SIZE, IMAGE_MAX_WIDTH, IMAGE_MAX_HEIGHT,
API_IMAGE_MAX_BASE64_SIZE) inside maybeResizeAndDownsampleImageBuffer.
When undefined, behavior is unchanged for current callers.

Add _meta preservation in the text-block case of transformResultContent
(only when the caller opts in via includeMeta=true). transformMCPResult
passes includeMeta=true on the tool-result path; the prompt-handler call
site keeps the default false, preserving prior behavior.

Add skipLargeOutput early-return in processMCPResult after the IDE check:
when the caller passes skipLargeOutput=true and the content has no images,
the function returns content directly without large-output handling.

Add unwrap-to-text in processMCPResult for the persisted-content path:
when the large-string format gate is enabled
(MCP_TRUNCATION_PROMPT_OVERRIDE env var, or
tengu_mcp_subagent_prompt Statsig gate), and the content is a single
bare text block (no annotations, no _meta), unwrap to raw text and
switch the format description to 'Plain text'. Default-off; gate-off
behavior is unchanged.

Verified structurally against the 2.1.128 binary: function signatures,
the IDE check, gate logic, _meta-unwrap pattern, and imageLimits
plumbing match this implementation.
This commit is contained in:
shaleloop
2026-05-05 10:14:38 -07:00
parent 872ee280e3
commit 26ddbda849
2 changed files with 141 additions and 36 deletions

View File

@@ -74,7 +74,10 @@ import {
} from '../../utils/errors.js'
import { getMCPUserAgent } from '../../utils/http.js'
import { maybeNotifyIDEConnected } from '../../utils/ide.js'
import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js'
import {
type ImageLimits,
maybeResizeAndDownsampleImageBuffer,
} from '../../utils/imageResizer.js'
import { logMCPDebug, logMCPError } from '../../utils/log.js'
import {
getBinaryBlobSavedMessage,
@@ -102,6 +105,7 @@ import {
isPersistError,
persistToolResult,
} from '../../utils/toolResultStorage.js'
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -2486,15 +2490,23 @@ export function prefetchAllMcpResources(
export async function transformResultContent(
resultContent: PromptMessage['content'],
serverName: string,
limits?: ImageLimits,
includeMeta = false,
): Promise<Array<ContentBlockParam>> {
switch (resultContent.type) {
case 'text':
return [
{
type: 'text',
text: resultContent.text,
},
]
case 'text': {
const block: ContentBlockParam = {
type: 'text',
text: resultContent.text,
}
if (includeMeta) {
const meta = resultContent._meta
if (meta) {
;(block as { _meta?: unknown })._meta = meta
}
}
return [block]
}
case 'audio': {
const audioData = resultContent as {
type: 'audio'
@@ -2516,6 +2528,7 @@ export async function transformResultContent(
imageBuffer,
imageBuffer.length,
ext,
limits,
)
return [
{
@@ -2551,6 +2564,7 @@ export async function transformResultContent(
imageBuffer,
imageBuffer.length,
ext,
limits,
)
const content: MessageParam['content'] = []
if (prefix) {
@@ -2671,6 +2685,7 @@ export async function transformMCPResult(
result: unknown,
tool: string, // Tool name for validation (e.g., "search")
name: string, // Server name for transformation (e.g., "slack")
limits?: ImageLimits, // Image processing limits, plumbed to transformResultContent
): Promise<TransformedMCPResult> {
if (result && typeof result === 'object') {
if ('toolResult' in result) {
@@ -2694,7 +2709,9 @@ export async function transformMCPResult(
if ('content' in result && Array.isArray(result.content)) {
const transformedContent = (
await Promise.all(
result.content.map(item => transformResultContent(item, name)),
result.content.map(item =>
transformResultContent(item, name, limits, true),
),
)
).flat()
return {
@@ -2729,8 +2746,15 @@ export async function processMCPResult(
result: unknown,
tool: string, // Tool name for validation (e.g., "search")
name: string, // Server name for IDE check and transformation (e.g., "slack")
limits?: ImageLimits, // Image processing limits, plumbed to transformMCPResult
skipLargeOutput = false, // If true, skip large-output handling for non-image content
): Promise<MCPToolResult> {
const { content, type, schema } = await transformMCPResult(result, tool, name)
const { content, type, schema } = await transformMCPResult(
result,
tool,
name,
limits,
)
// IDE tools are not going to the model directly, so we don't need to
// handle large output.
@@ -2738,6 +2762,12 @@ export async function processMCPResult(
return content
}
// Caller opted out of large-output handling (e.g., result already truncated
// upstream); only continue if the content has images that may need handling.
if (skipLargeOutput && !contentContainsImages(content)) {
return content
}
// Check if content needs truncation (i.e., is too large)
if (!(await mcpContentNeedsTruncation(content))) {
return content
@@ -2775,9 +2805,15 @@ export async function processMCPResult(
// Generate a unique ID for the persisted file (server__tool-timestamp)
const timestamp = Date.now()
const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${timestamp}`
// Convert to string for persistence (persistToolResult expects string or specific block types)
// When the large-string format gate is on, unwrap a single bare text block
// (no annotations, no _meta) into raw text so the model gets plain text in
// the persisted file instead of a JSON-wrapped block. The `_meta` check is
// why transformResultContent preserves _meta on text blocks.
const unwrappedText = unwrapSingleTextBlock(content)
const contentStr =
typeof content === 'string' ? content : jsonStringify(content, null, 2)
typeof content === 'string'
? content
: (unwrappedText ?? jsonStringify(content, null, 2))
const persistResult = await persistToolResult(contentStr, persistId)
if (isPersistError(persistResult)) {
@@ -2798,7 +2834,10 @@ export async function processMCPResult(
persistedSizeChars: persistResult.originalSize,
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
const formatDescription = getFormatDescription(type, schema)
const formatDescription =
unwrappedText !== undefined
? getFormatDescription('toolResult')
: getFormatDescription(type, schema)
return getLargeOutputInstructions(
persistResult.filepath,
persistResult.originalSize,
@@ -2806,6 +2845,39 @@ export async function processMCPResult(
)
}
/**
* Returns true when the large-string output format is enabled (matches
* binary's `sf8()`). When enabled, processMCPResult unwraps a single bare
* text block to raw text for persistence instead of JSON-wrapping it.
*
* Gating sources, in order:
* 1. MCP_TRUNCATION_PROMPT_OVERRIDE env var (anything except "legacy" enables)
* 2. Statsig gate `tengu_mcp_subagent_prompt`
*/
function isLargeStringFormatEnabled(): boolean {
const override = process.env.MCP_TRUNCATION_PROMPT_OVERRIDE
if (override) return override !== 'legacy'
return checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
'tengu_mcp_subagent_prompt',
)
}
/**
* Unwraps a single bare text content block to its raw text when the
* large-string format gate is on. Returns undefined when the gate is off,
* the content is not an array, the array doesn't contain exactly one text
* block, or the block carries annotations or _meta. Matches binary mg5's
* `M=...` computation.
*/
function unwrapSingleTextBlock(content: MCPToolResult): string | undefined {
if (!isLargeStringFormatEnabled()) return undefined
if (!Array.isArray(content) || content.length !== 1) return undefined
const block = content[0]
if (!block || block.type !== 'text') return undefined
if ('annotations' in block || '_meta' in block) return undefined
return block.text
}
/**
* Call an MCP tool, handling UrlElicitationRequiredError (-32042) by
* displaying the URL elicitation to the user, waiting for the completion
@@ -2827,6 +2899,8 @@ export async function callMCPToolWithUrlElicitationRetry({
signal,
setAppState,
onProgress,
imageLimits,
hasResultSizeAnnotation = false,
callToolFn = callMCPTool,
handleElicitation,
}: {
@@ -2838,6 +2912,8 @@ export async function callMCPToolWithUrlElicitationRetry({
signal: AbortSignal
setAppState: (f: (prev: AppState) => AppState) => void
onProgress?: (data: MCPProgress) => void
imageLimits?: ImageLimits
hasResultSizeAnnotation?: boolean
/** Injectable for testing. Defaults to callMCPTool. */
callToolFn?: (opts: {
client: ConnectedMCPServer
@@ -2846,6 +2922,8 @@ export async function callMCPToolWithUrlElicitationRetry({
meta?: Record<string, unknown>
signal: AbortSignal
onProgress?: (data: MCPProgress) => void
imageLimits?: ImageLimits
hasResultSizeAnnotation?: boolean
}) => Promise<MCPToolCallResult>
/** Handler for URL elicitations when no hook handles them.
* In print/SDK mode, delegates to structuredIO. In REPL, falls back to queue. */
@@ -2865,6 +2943,8 @@ export async function callMCPToolWithUrlElicitationRetry({
meta,
signal,
onProgress,
imageLimits,
hasResultSizeAnnotation,
})
} catch (error) {
// The MCP SDK's Protocol creates plain McpError (not UrlElicitationRequiredError)
@@ -3041,6 +3121,8 @@ async function callMCPTool({
meta,
signal,
onProgress,
imageLimits,
hasResultSizeAnnotation = false,
}: {
client: ConnectedMCPServer
tool: string
@@ -3048,6 +3130,8 @@ async function callMCPTool({
meta?: Record<string, unknown>
signal: AbortSignal
onProgress?: (data: MCPProgress) => void
imageLimits?: ImageLimits
hasResultSizeAnnotation?: boolean
}): Promise<{
content: MCPToolResult
_meta?: Record<string, unknown>
@@ -3176,7 +3260,13 @@ async function callMCPTool({
})
}
const content = await processMCPResult(result, tool, name)
const content = await processMCPResult(
result,
tool,
name,
imageLimits,
hasResultSizeAnnotation,
)
return {
content,
_meta: result._meta as Record<string, unknown> | undefined,

View File

@@ -162,6 +162,16 @@ interface CompressedImageResult {
originalSize: number
}
/**
* Per-call image processing limits, overriding the module-level defaults.
*/
export interface ImageLimits {
targetRawSize: number
maxWidth: number
maxHeight: number
maxBase64Size: number
}
/**
* Extracted from FileReadTool's readImage function
* Resizes image buffer to meet size and dimension constraints
@@ -170,7 +180,13 @@ export async function maybeResizeAndDownsampleImageBuffer(
imageBuffer: Buffer,
originalSize: number,
ext: string,
limits?: ImageLimits,
): Promise<ResizeResult> {
const targetRawSize = limits?.targetRawSize ?? IMAGE_TARGET_RAW_SIZE
const maxWidth = limits?.maxWidth ?? IMAGE_MAX_WIDTH
const maxHeight = limits?.maxHeight ?? IMAGE_MAX_HEIGHT
const maxBase64Size = limits?.maxBase64Size ?? API_IMAGE_MAX_BASE64_SIZE
if (imageBuffer.length === 0) {
// Empty buffer would fall through the catch block below (sharp throws
// "Unable to determine image format"), and the fallback's size check
@@ -189,7 +205,7 @@ export async function maybeResizeAndDownsampleImageBuffer(
// If dimensions aren't available from metadata
if (!metadata.width || !metadata.height) {
if (originalSize > IMAGE_TARGET_RAW_SIZE) {
if (originalSize > targetRawSize) {
// Create fresh sharp instance for compression
const compressedBuffer = await sharp(imageBuffer)
.jpeg({ quality: 80 })
@@ -210,9 +226,9 @@ export async function maybeResizeAndDownsampleImageBuffer(
// Check if the original file just works
if (
originalSize <= IMAGE_TARGET_RAW_SIZE &&
width <= IMAGE_MAX_WIDTH &&
height <= IMAGE_MAX_HEIGHT
originalSize <= targetRawSize &&
width <= maxWidth &&
height <= maxHeight
) {
return {
buffer: imageBuffer,
@@ -226,20 +242,19 @@ export async function maybeResizeAndDownsampleImageBuffer(
}
}
const needsDimensionResize =
width > IMAGE_MAX_WIDTH || height > IMAGE_MAX_HEIGHT
const needsDimensionResize = width > maxWidth || height > maxHeight
const isPng = normalizedMediaType === 'png'
// If dimensions are within limits but file is too large, try compression first
// This preserves full resolution when possible
if (!needsDimensionResize && originalSize > IMAGE_TARGET_RAW_SIZE) {
if (!needsDimensionResize && originalSize > targetRawSize) {
// For PNGs, try PNG compression first to preserve transparency
if (isPng) {
// Create fresh sharp instance for each compression attempt
const pngCompressed = await sharp(imageBuffer)
.png({ compressionLevel: 9, palette: true })
.toBuffer()
if (pngCompressed.length <= IMAGE_TARGET_RAW_SIZE) {
if (pngCompressed.length <= targetRawSize) {
return {
buffer: pngCompressed,
mediaType: 'png',
@@ -258,7 +273,7 @@ export async function maybeResizeAndDownsampleImageBuffer(
const compressedBuffer = await sharp(imageBuffer)
.jpeg({ quality })
.toBuffer()
if (compressedBuffer.length <= IMAGE_TARGET_RAW_SIZE) {
if (compressedBuffer.length <= targetRawSize) {
return {
buffer: compressedBuffer,
mediaType: 'jpeg',
@@ -275,14 +290,14 @@ export async function maybeResizeAndDownsampleImageBuffer(
}
// Constrain dimensions if needed
if (width > IMAGE_MAX_WIDTH) {
height = Math.round((height * IMAGE_MAX_WIDTH) / width)
width = IMAGE_MAX_WIDTH
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width)
width = maxWidth
}
if (height > IMAGE_MAX_HEIGHT) {
width = Math.round((width * IMAGE_MAX_HEIGHT) / height)
height = IMAGE_MAX_HEIGHT
if (height > maxHeight) {
width = Math.round((width * maxHeight) / height)
height = maxHeight
}
// IMPORTANT: Always create fresh sharp(imageBuffer) instances for each operation.
@@ -298,7 +313,7 @@ export async function maybeResizeAndDownsampleImageBuffer(
.toBuffer()
// If still too large after resize, try compression
if (resizedImageBuffer.length > IMAGE_TARGET_RAW_SIZE) {
if (resizedImageBuffer.length > targetRawSize) {
// For PNGs, try PNG compression first to preserve transparency
if (isPng) {
const pngCompressed = await sharp(imageBuffer)
@@ -308,7 +323,7 @@ export async function maybeResizeAndDownsampleImageBuffer(
})
.png({ compressionLevel: 9, palette: true })
.toBuffer()
if (pngCompressed.length <= IMAGE_TARGET_RAW_SIZE) {
if (pngCompressed.length <= targetRawSize) {
return {
buffer: pngCompressed,
mediaType: 'png',
@@ -331,7 +346,7 @@ export async function maybeResizeAndDownsampleImageBuffer(
})
.jpeg({ quality })
.toBuffer()
if (compressedBuffer.length <= IMAGE_TARGET_RAW_SIZE) {
if (compressedBuffer.length <= targetRawSize) {
return {
buffer: compressedBuffer,
mediaType: 'jpeg',
@@ -407,11 +422,11 @@ export async function maybeResizeAndDownsampleImageBuffer(
imageBuffer[1] === 0x50 &&
imageBuffer[2] === 0x4e &&
imageBuffer[3] === 0x47 &&
(imageBuffer.readUInt32BE(16) > IMAGE_MAX_WIDTH ||
imageBuffer.readUInt32BE(20) > IMAGE_MAX_HEIGHT)
(imageBuffer.readUInt32BE(16) > maxWidth ||
imageBuffer.readUInt32BE(20) > maxHeight)
// If original image's base64 encoding is within API limit, allow it through uncompressed
if (base64Size <= API_IMAGE_MAX_BASE64_SIZE && !overDim) {
if (base64Size <= maxBase64Size && !overDim) {
logEvent('tengu_image_resize_fallback', {
original_size_bytes: originalSize,
base64_size_bytes: base64Size,
@@ -423,7 +438,7 @@ export async function maybeResizeAndDownsampleImageBuffer(
// Image is too large and we failed to compress it - fail with user-friendly error
throw new ImageResizeError(
overDim
? `Unable to resize image — dimensions exceed the ${IMAGE_MAX_WIDTH}x${IMAGE_MAX_HEIGHT}px limit and image processing failed. ` +
? `Unable to resize image — dimensions exceed the ${maxWidth}x${maxHeight}px limit and image processing failed. ` +
`Please resize the image to reduce its pixel dimensions.`
: `Unable to resize image (${formatFileSize(originalSize)} raw, ${formatFileSize(base64Size)} base64). ` +
`The image exceeds the 5MB API limit and compression failed. ` +