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' } from '../../utils/errors.js'
import { getMCPUserAgent } from '../../utils/http.js' import { getMCPUserAgent } from '../../utils/http.js'
import { maybeNotifyIDEConnected } from '../../utils/ide.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 { logMCPDebug, logMCPError } from '../../utils/log.js'
import { import {
getBinaryBlobSavedMessage, getBinaryBlobSavedMessage,
@@ -102,6 +105,7 @@ import {
isPersistError, isPersistError,
persistToolResult, persistToolResult,
} from '../../utils/toolResultStorage.js' } from '../../utils/toolResultStorage.js'
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
import { import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent, logEvent,
@@ -2486,15 +2490,23 @@ export function prefetchAllMcpResources(
export async function transformResultContent( export async function transformResultContent(
resultContent: PromptMessage['content'], resultContent: PromptMessage['content'],
serverName: string, serverName: string,
limits?: ImageLimits,
includeMeta = false,
): Promise<Array<ContentBlockParam>> { ): Promise<Array<ContentBlockParam>> {
switch (resultContent.type) { switch (resultContent.type) {
case 'text': case 'text': {
return [ const block: ContentBlockParam = {
{ type: 'text',
type: 'text', text: resultContent.text,
text: resultContent.text, }
}, if (includeMeta) {
] const meta = resultContent._meta
if (meta) {
;(block as { _meta?: unknown })._meta = meta
}
}
return [block]
}
case 'audio': { case 'audio': {
const audioData = resultContent as { const audioData = resultContent as {
type: 'audio' type: 'audio'
@@ -2516,6 +2528,7 @@ export async function transformResultContent(
imageBuffer, imageBuffer,
imageBuffer.length, imageBuffer.length,
ext, ext,
limits,
) )
return [ return [
{ {
@@ -2551,6 +2564,7 @@ export async function transformResultContent(
imageBuffer, imageBuffer,
imageBuffer.length, imageBuffer.length,
ext, ext,
limits,
) )
const content: MessageParam['content'] = [] const content: MessageParam['content'] = []
if (prefix) { if (prefix) {
@@ -2671,6 +2685,7 @@ export async function transformMCPResult(
result: unknown, result: unknown,
tool: string, // Tool name for validation (e.g., "search") tool: string, // Tool name for validation (e.g., "search")
name: string, // Server name for transformation (e.g., "slack") name: string, // Server name for transformation (e.g., "slack")
limits?: ImageLimits, // Image processing limits, plumbed to transformResultContent
): Promise<TransformedMCPResult> { ): Promise<TransformedMCPResult> {
if (result && typeof result === 'object') { if (result && typeof result === 'object') {
if ('toolResult' in result) { if ('toolResult' in result) {
@@ -2694,7 +2709,9 @@ export async function transformMCPResult(
if ('content' in result && Array.isArray(result.content)) { if ('content' in result && Array.isArray(result.content)) {
const transformedContent = ( const transformedContent = (
await Promise.all( await Promise.all(
result.content.map(item => transformResultContent(item, name)), result.content.map(item =>
transformResultContent(item, name, limits, true),
),
) )
).flat() ).flat()
return { return {
@@ -2729,8 +2746,15 @@ export async function processMCPResult(
result: unknown, result: unknown,
tool: string, // Tool name for validation (e.g., "search") tool: string, // Tool name for validation (e.g., "search")
name: string, // Server name for IDE check and transformation (e.g., "slack") 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> { ): 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 // IDE tools are not going to the model directly, so we don't need to
// handle large output. // handle large output.
@@ -2738,6 +2762,12 @@ export async function processMCPResult(
return content 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) // Check if content needs truncation (i.e., is too large)
if (!(await mcpContentNeedsTruncation(content))) { if (!(await mcpContentNeedsTruncation(content))) {
return content return content
@@ -2775,9 +2805,15 @@ export async function processMCPResult(
// Generate a unique ID for the persisted file (server__tool-timestamp) // Generate a unique ID for the persisted file (server__tool-timestamp)
const timestamp = Date.now() const timestamp = Date.now()
const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${timestamp}` 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 = 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) const persistResult = await persistToolResult(contentStr, persistId)
if (isPersistError(persistResult)) { if (isPersistError(persistResult)) {
@@ -2798,7 +2834,10 @@ export async function processMCPResult(
persistedSizeChars: persistResult.originalSize, persistedSizeChars: persistResult.originalSize,
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) } 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( return getLargeOutputInstructions(
persistResult.filepath, persistResult.filepath,
persistResult.originalSize, 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 * Call an MCP tool, handling UrlElicitationRequiredError (-32042) by
* displaying the URL elicitation to the user, waiting for the completion * displaying the URL elicitation to the user, waiting for the completion
@@ -2827,6 +2899,8 @@ export async function callMCPToolWithUrlElicitationRetry({
signal, signal,
setAppState, setAppState,
onProgress, onProgress,
imageLimits,
hasResultSizeAnnotation = false,
callToolFn = callMCPTool, callToolFn = callMCPTool,
handleElicitation, handleElicitation,
}: { }: {
@@ -2838,6 +2912,8 @@ export async function callMCPToolWithUrlElicitationRetry({
signal: AbortSignal signal: AbortSignal
setAppState: (f: (prev: AppState) => AppState) => void setAppState: (f: (prev: AppState) => AppState) => void
onProgress?: (data: MCPProgress) => void onProgress?: (data: MCPProgress) => void
imageLimits?: ImageLimits
hasResultSizeAnnotation?: boolean
/** Injectable for testing. Defaults to callMCPTool. */ /** Injectable for testing. Defaults to callMCPTool. */
callToolFn?: (opts: { callToolFn?: (opts: {
client: ConnectedMCPServer client: ConnectedMCPServer
@@ -2846,6 +2922,8 @@ export async function callMCPToolWithUrlElicitationRetry({
meta?: Record<string, unknown> meta?: Record<string, unknown>
signal: AbortSignal signal: AbortSignal
onProgress?: (data: MCPProgress) => void onProgress?: (data: MCPProgress) => void
imageLimits?: ImageLimits
hasResultSizeAnnotation?: boolean
}) => Promise<MCPToolCallResult> }) => Promise<MCPToolCallResult>
/** Handler for URL elicitations when no hook handles them. /** Handler for URL elicitations when no hook handles them.
* In print/SDK mode, delegates to structuredIO. In REPL, falls back to queue. */ * In print/SDK mode, delegates to structuredIO. In REPL, falls back to queue. */
@@ -2865,6 +2943,8 @@ export async function callMCPToolWithUrlElicitationRetry({
meta, meta,
signal, signal,
onProgress, onProgress,
imageLimits,
hasResultSizeAnnotation,
}) })
} catch (error) { } catch (error) {
// The MCP SDK's Protocol creates plain McpError (not UrlElicitationRequiredError) // The MCP SDK's Protocol creates plain McpError (not UrlElicitationRequiredError)
@@ -3041,6 +3121,8 @@ async function callMCPTool({
meta, meta,
signal, signal,
onProgress, onProgress,
imageLimits,
hasResultSizeAnnotation = false,
}: { }: {
client: ConnectedMCPServer client: ConnectedMCPServer
tool: string tool: string
@@ -3048,6 +3130,8 @@ async function callMCPTool({
meta?: Record<string, unknown> meta?: Record<string, unknown>
signal: AbortSignal signal: AbortSignal
onProgress?: (data: MCPProgress) => void onProgress?: (data: MCPProgress) => void
imageLimits?: ImageLimits
hasResultSizeAnnotation?: boolean
}): Promise<{ }): Promise<{
content: MCPToolResult content: MCPToolResult
_meta?: Record<string, unknown> _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 { return {
content, content,
_meta: result._meta as Record<string, unknown> | undefined, _meta: result._meta as Record<string, unknown> | undefined,

View File

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