Skip to content

Image content blocks >99 bytes fail with "illegal base64 at byte 0" #3123

Description

@PeeperFrog

Image Content Blocks >99 Bytes Fail Client-Side Decode in stdio Mode

Summary

Image content blocks returned via _mcp_content_blocks fail client-side base64 decoding when the decoded image bytes exceed 99 bytes, despite the server emitting spec-compliant MCP protocol messages. The issue manifests as "illegal base64 data at input byte 0" errors in the client, while server wire logs confirm correct base64 encoding with proper padding.

Environment

Server:

  • Python SDK: mcp==1.28.1
  • Python: 3.12
  • Transport: stdio (JSON-RPC over stdin/stdout)
  • OS: Linux (Ubuntu-based)

Client:

  • Claude Code (Anthropic's MCP client)
  • Error source: Go encoding/base64 (based on error message format)

Dependencies:

mcp==1.28.1
starlette==1.3.1
pydantic==2.13.4
fastapi==0.139.2

Issue Description

Observed Behavior

Images returned in MCP content blocks exhibit a binary size threshold at exactly 100 bytes (decoded image data):

Decoded Bytes Base64 Chars Result Error
≤99 ≤132 ✅ Renders None
≥100 ≥136 ❌ Fails "illegal base64 data at input byte 0"

Test Results

Pass Class (renders successfully):

  • 48 bytes (64 base64 chars): ✅ Visual confirmation
  • 99 bytes (132 base64 chars): ✅ Visual confirmation

Fail Class (decode error):

  • 102 bytes (136 base64 chars): ❌ "illegal base64 data at input byte 0"
  • 4,412 bytes (5,884 base64 chars): ❌ "illegal base64 data at input byte 0"
  • 5,108 bytes (6,812 base64 chars): ❌ "illegal base64 data at input byte 0"

Additional Finding (SDK Version Dependent)

With SDK 1.26.0, payloads >4KB exhibited a second error class:

  • "Incorrect padding" (Python binascii error)

This was fixed by upgrading to SDK 1.28.1, leaving only the 100-byte threshold issue.

Wire-Level Evidence

Server logs confirm spec-compliant image blocks are emitted:

[ISS-011-WIRE] Image block: data_len=64, first_char='U', last_char='B', 
               has_mimeType=True, block_keys=['type', 'data', 'mimeType']
Result: ✅ RENDERED

[ISS-011-WIRE] Image block: data_len=6812, first_char='U', last_char='=', 
               has_mimeType=True, block_keys=['type', 'data', 'mimeType']
Result: ❌ illegal base64 data at input byte 0

[ISS-011-WIRE] Image block: data_len=5884, first_char='U', last_char='=', 
               has_mimeType=True, block_keys=['type', 'data', 'mimeType']
Result: ❌ illegal base64 data at input byte 0

Analysis:

  • ✅ Correct keys: ['type', 'data', 'mimeType']
  • ✅ Valid base64 start: 'U' (valid character)
  • ✅ Proper padding: '=' where expected
  • ✅ No wrapping, prefixing, or unexpected keys
  • ❌ Client receives "illegal at byte 0" despite clean server output

Reproduction

Minimal Server Code

import base64
from PIL import Image
import io

def img_test_base64_transport(test_variant: int = 0):
    """
    Test image content blocks at different sizes.
    Variant 0: 48 bytes (passes)
    Variant 1: 102 bytes (fails)
    """
    if test_variant == 0:
        # Small image: 4x4 solid color
        img = Image.new('RGB', (4, 4), color=(128, 128, 128))
    else:
        # Larger image: ~102 bytes
        img = Image.new('RGB', (6, 6), color=(128, 128, 128))
    
    buf = io.BytesIO()
    img.save(buf, format='WEBP', quality=100, method=6)
    webp_bytes = buf.getvalue()
    
    # Encode to base64
    b64_data = base64.b64encode(webp_bytes).decode("utf-8")
    
    # Ensure padding (though b64encode already does this)
    missing_padding = len(b64_data) % 4
    if missing_padding:
        b64_data += '=' * (4 - missing_padding)
    
    # Return as MCP image content block
    return {
        "success": True,
        "test_variant": test_variant,
        "actual_b64_length": len(b64_data),
        "bytes_emitted": len(webp_bytes),
        "b64_padding_check": len(b64_data) % 4,
        "mime_type": "image/webp",
        "_mcp_content_blocks": [
            {
                "type": "image",
                "data": b64_data,
                "mimeType": "image/webp"
            }
        ]
    }

MCP Tool Registration

@server.call_tool()
async def test_image_size(test_variant: int = 0):
    """Test image at different sizes to reproduce 100-byte threshold bug."""
    result = img_test_base64_transport(test_variant)
    
    # Extract content blocks
    content_blocks = result.pop("_mcp_content_blocks", [])
    
    # Return content blocks + metadata
    return content_blocks + [{"type": "text", "text": json.dumps(result)}]

Steps to Reproduce

  1. Run MCP server with test tool above
  2. Connect with Claude Code client
  3. Call test_image_size(test_variant=0) → ✅ Image renders
  4. Call test_image_size(test_variant=1) → ❌ "illegal base64 data at input byte 0"

Expected Behavior

Image content blocks should render regardless of size, as long as:

  1. Base64 encoding is valid (length % 4 == 0)
  2. Decoded bytes are valid WebP/image data
  3. Block structure follows MCP spec: {"type": "image", "data": "<base64>", "mimeType": "image/webp"}

Actual Behavior

Images >99 decoded bytes fail with:

Image '<uuid>.webp' could not be processed: illegal base64 data at input byte 0

This error indicates the first character received by the client decoder is not valid base64, despite server logs showing first_char='U' (valid base64).

Root Cause Analysis

The evidence suggests:

  1. Server-side is correct:

    • Wire logs show proper base64 encoding
    • First/last characters are valid
    • Padding is correct (len % 4 == 0)
    • No unexpected keys or wrapping
  2. Client-side has size threshold:

    • Exact boundary at 100 decoded bytes
    • Error message from Go encoding/base64 (not Python)
    • Suggests client code path branches on size:
      if len(imageBytes) > 100 {
          // Wrapper/attachment path (breaks base64)
      } else {
          // Inline path (works)
      }
  3. Hypothesis:

    • Client wraps/prefixes large payloads before decoding
    • Wrapper invalidates base64 (illegal first byte)
    • 100 bytes is a human-round-number threshold (not power-of-2)

Impact

  • Thumbnails fail: 128x128 WebP thumbnails are ~3-5KB (always fail)
  • Image preview broken: Cannot display images in MCP responses
  • Workaround required: External file references instead of inline images

Diagnostic Data Available

We have comprehensive test results including:

  • 10 test fixtures spanning 48-6,812 bytes
  • Server-side wire logs for all sizes
  • Exact boundary detection (99 vs 100 bytes)
  • SDK version comparison (1.26.0 vs 1.28.1)

Happy to provide additional test cases or logs if needed.

Related Issues

Proposed Fix

Since server-side emits correct data, the fix likely needs to be in:

  1. Client SDK (Claude Code's MCP harness) - remove size-based branching for image content blocks
  2. Or MCP spec clarification - if >100-byte images require different encoding, document it

CC: @anthropics/mcp-team

Test server available: We have a fully instrumented test server with wire-level logging if needed for debugging.

Activity

  1. PeeperFrog commented on Jul 18, 2026

    @PeeperFrog
    Author

    Minimal Reproduction Server:

    #!/usr/bin/env python3
    """
    Minimal MCP Server to Reproduce 100-Byte Image Content Block Bug
    
    This server demonstrates the issue where image content blocks >99 bytes
    fail client-side decode with "illegal base64 data at input byte 0".
    
    Usage:
        python minimal-repro-server.py
    
    Then connect with Claude Code and run:
        test_image_size(test_variant=0)  # 48 bytes - ✅ Works
        test_image_size(test_variant=1)  # 102 bytes - ❌ Fails
    """
    
    import sys
    import json
    import base64
    from PIL import Image
    import io
    from mcp.server import Server
    from mcp.server.stdio import stdio_server
    from mcp.types import Tool, TextContent, ImageContent
    
    # Create server
    server = Server("image-size-test")
    
    def create_webp_image(size_variant: int) -> bytes:
        """
        Create a WebP image of specific size.
    
        Variant 0: 4x4 = ~48 bytes (passes 100-byte threshold)
        Variant 1: 6x6 = ~102 bytes (fails 100-byte threshold)
        """
        sizes = {
            0: (4, 4),   # ~48 bytes WebP
            1: (6, 6),   # ~102 bytes WebP
        }
    
        img_size = sizes.get(size_variant, (4, 4))
        img = Image.new('RGB', img_size, color=(128, 128, 128))
    
        buf = io.BytesIO()
        img.save(buf, format='WEBP', quality=100, method=6)
        return buf.getvalue()
    
    @server.list_tools()
    async def list_tools() -> list[Tool]:
        """List available tools."""
        return [
            Tool(
                name="test_image_size",
                description="Test image content blocks at different sizes to reproduce 100-byte threshold bug",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "test_variant": {
                            "type": "integer",
                            "description": "0 = 48 bytes (passes), 1 = 102 bytes (fails)",
                            "default": 0,
                            "minimum": 0,
                            "maximum": 1
                        }
                    }
                }
            )
        ]
    
    @server.call_tool()
    async def call_tool(name: str, arguments: dict):
        """Handle tool calls."""
        if name != "test_image_size":
            raise ValueError(f"Unknown tool: {name}")
    
        test_variant = arguments.get("test_variant", 0)
    
        # Create WebP image
        webp_bytes = create_webp_image(test_variant)
    
        # Encode to base64
        b64_data = base64.b64encode(webp_bytes).decode("utf-8")
    
        # Ensure proper padding (though b64encode already handles this)
        missing_padding = len(b64_data) % 4
        if missing_padding:
            b64_data += '=' * (4 - missing_padding)
    
        # Log wire-level details to stderr (won't interfere with stdio protocol)
        sys.stderr.write(
            f"[WIRE] test_variant={test_variant}, "
            f"bytes={len(webp_bytes)}, "
            f"b64_len={len(b64_data)}, "
            f"b64_len%4={len(b64_data) % 4}, "
            f"first_char='{b64_data[0]}', "
            f"last_char='{b64_data[-1]}'\n"
        )
        sys.stderr.flush()
    
        # Return image content block + metadata
        return [
            ImageContent(
                type="image",
                data=b64_data,
                mimeType="image/webp"
            ),
            TextContent(
                type="text",
                text=json.dumps({
                    "success": True,
                    "test_variant": test_variant,
                    "bytes_emitted": len(webp_bytes),
                    "b64_length": len(b64_data),
                    "b64_padding_check": len(b64_data) % 4,
                    "first_char": b64_data[0],
                    "last_char": b64_data[-1],
                    "expected_result": "✅ Renders" if test_variant == 0 else "❌ Fails with 'illegal base64 at byte 0'"
                })
            )
        ]
    
    async def main():
        """Run the server."""
        sys.stderr.write("MCP Image Size Test Server Starting\n")
        sys.stderr.write("This server reproduces the 100-byte threshold bug:\n")
        sys.stderr.write("  test_variant=0: 48 bytes (should render)\n")
        sys.stderr.write("  test_variant=1: 102 bytes (fails with 'illegal base64 at byte 0')\n\n")
        sys.stderr.flush()
    
        async with stdio_server() as (read_stream, write_stream):
            await server.run(
                read_stream,
                write_stream,
                server.create_initialization_options()
            )
    
    if __name__ == "__main__":
        import asyncio
        asyncio.run(main())

    Requirements:

    # Minimal Requirements for MCP Image Bug Reproduction
    mcp>=1.28.1
    Pillow>=10.0.0
    

    Usage:

    pip install -r requirements-repro.txt
    python minimal-repro-server.py

    Then connect with Claude Code and run:

    • test_image_size(test_variant=0) → ✅ 48 bytes, renders
    • test_image_size(test_variant=1) → ❌ 102 bytes, fails with 'illegal base64 at byte 0'
  2. PeeperFrog commented on Jul 18, 2026

    @PeeperFrog
    Author

    Temporary Workaround Implemented

    While awaiting fix, we've deployed a hybrid thumbnail delivery system:

    Implementation:

    • Images ≤99 bytes: inline base64 (works, optimal)
    • Images ≥100 bytes: return file path instead (sidesteps bug)

    Code:

    if len(thumb_bytes) <= 99:
        # Inline mode (works fine)
        return {
            'delivery_mode': 'inline',
            '_mcp_content_blocks': [{
                'type': 'image',
                'data': base64_string,
                'mimeType': 'image/webp'
            }]
        }
    else:
        # File path mode (workaround)
        return {
            'delivery_mode': 'file_path',
            'thumbnail_path': '/path/to/thumbnail.webp',
            'workaround_note': 'MCP SDK issue #3123'
        }

    Results:

    • ✅ Small thumbnails render inline (fast)
    • ✅ Large thumbnails accessible via file path
    • ✅ No size limits
    • ✅ Automatic failover

    This workaround will be removed once SDK fix is deployed.

  3. PeeperFrog commented on Jul 18, 2026

    @PeeperFrog
    Author

    ✅ Root Cause Identified: Entropy-Based Redaction Pre-Decode

    After 7 test rounds over 6 days with discriminating experiments, root cause confirmed:

    Claude.ai's tool-result pipeline applies high-entropy string redaction to base64 image payloads inside ImageContent and EmbeddedResource blocks BEFORE image decoding, replacing valid data and causing "illegal base64 data at input byte 0" errors.


    Discriminating Test Results (2026-07-17)

    Built entropy-discriminating test with Shannon entropy calculation:

    Pattern b64 Length Entropy (bits/char) Distinct Chars Result
    Solid PNG cl=0 1,136 chars 0.714 37/65 ✅ RENDERS
    Solid PNG cl=0 4,232 chars 0.272 39/65 ✅ RENDERS
    Gradient WebP q95 112 chars 4.891 44/65 ❌ Byte 0 error
    Gradient WebP q95 196 chars 5.434 58/65 ❌ Byte 0 error

    Key finding: 4,232-char low-entropy (0.27 bits/char) payload renders successfully. 112-char high-entropy (4.89 bits/char) payload fails at byte 0.

    Size is irrelevant. Entropy is everything.


    Entropy Threshold

    • Passes: ≤0.714 bits/char (solid gray PNG, compress_level=0)
    • Fails: ≥4.891 bits/char (compressed gradient WebP)
    • Untested: 0.72–4.89 bits/char middle range

    For reference: typical compressed images (JPEG/WebP/PNG compress_level=6-9) have ~5.5-6.0 bits/char Shannon entropy.


    Error Source Attribution

    Error phrasing "illegal base64 data at input byte N" is Go's encoding/base64 exact format (not Python binascii, not TypeScript, not MCP SDK error messages).

    This points to Anthropic's Claude.ai server-side ingestion layer (Go-based), not the MCP Python SDK.

    The bug manifests when:

    1. MCP server emits valid base64 ImageContent/EmbeddedResource block (verified via server-side diagnostics)
    2. Claude.ai ingestion pipeline detects high-entropy string in tool result
    3. Redaction filter replaces base64 blob with "[REDACTED: high_entropy_string]"
    4. Image decoder receives "[" as first character
    5. Decoder fails at byte 0: "illegal base64 data at input byte 0"

    This explains all observations across 7 test rounds and matches the known behavior of Claude.ai redacting high-entropy strings in data URIs.


    Server-Side Verification

    Wire-level logging confirmed server emits perfect, valid base64:

    [DELIVERY-TEST] b64_data type: <class 'str'>
    [DELIVERY-TEST] b64_data[:24]: 'UklGRmwAAABXRUJQVlA4IGAA'
    [DELIVERY-TEST] first_char: 'U'
    [DELIVERY-TEST] ✓ base64 valid, decodes to 116 bytes
    [DELIVERY-TEST] Shannon entropy: 5.434 bits/char
    

    Yet client receives: "illegal base64 data at input byte 0"

    Bug is definitively downstream of MCP SDK stdout — in Claude.ai's ingestion/redaction pipeline.


    Impact

    • All compressed images fail (WebP, JPEG, PNG with standard compression)
    • Only artificially low-entropy images work (solid colors, compress_level=0)
    • Real-world thumbnails/charts/photos inherently have high entropy when compressed
    • Defeats the purpose of MCP's image content block feature

    Workaround (Being Validated)

    Potential: Uncompressed PNG (compress_level=0) of real content if pixel redundancy keeps Shannon entropy <0.7 bits/char.

    Testing realistic chart/thumbnail content to validate if workaround is viable for production use.


    Recommendation

    This is a Claude.ai client-side bug, not an MCP SDK bug. Should be escalated to Anthropic's Claude.ai tool ingestion team:

    Redaction filter should NOT run on image data fields inside MCP content blocks. These are already typed as type: "image" with mimeType — there's no ambiguity requiring entropy-based heuristics.


    Test Code

    Entropy-discriminating test available at: https://github.com/PeeperFrog/noisk-ai-mcp/blob/18919c7b/src/NOISK_AI_server.py#L14006-14133

    Shannon entropy calculation:

    def shannon_entropy(s: str) -> float:
        from collections import Counter
        import math
        if not s:
            return 0.0
        counts = Counter(s)
        length = len(s)
        return -sum((count/length) * math.log2(count/length) for count in counts.values())

    Solid pattern (low entropy):

    img = Image.new('RGB', (16, 16), color=(128, 128, 128))
    img.save(buf, format='PNG', compress_level=0)  # Stored/raw deflate

    Status: Root cause identified with reproducible test. First successful image deliveries achieved. Awaiting Anthropic fix or workaround validation for real content.

  4. PeeperFrog commented on Jul 18, 2026

    @PeeperFrog
    Author

    ✅ Threshold Measured: 4.52 ± 0.04 bits/char Global Shannon Entropy

    After systematic bisection testing, the exact redaction threshold has been determined:

    Threshold: 4.52 ± 0.04 bits/char global Shannon entropy over the full payload (consistent with 4.5 exactly)


    Filter Mechanism Confirmed

    The redaction filter computes global Shannon entropy over the entire base64 payload.

    Alternative hypotheses eliminated:

    1. Window-based filtering: ❌ Eliminated

      • Test payload with sliding-window max 4.65 bits/char but global 0.64 → ✅ Passed
      • Payload with sliding max 2.25 but global 5.007 → ❌ Failed
    2. Run-based filtering: ❌ Eliminated

      • Payload with longest run 32 characters but global entropy 5.007 → ❌ Failed
      • Long repeated runs don't defeat filter if global entropy exceeds threshold
    3. Distinct-character count: ❌ Eliminated

      • Payloads with identical distinct-character counts split pass/fail based on global entropy alone
      • 36 distinct @ 5.007 entropy → ❌ Failed
      • 49 distinct @ 0.147 entropy → ✅ Passed

    Conclusion: Global Shannon entropy is the single determining factor.


    Production Workaround: Palette-Mode PNG

    Since all standard compression fails (PNG deflate at any level produces 5.24-5.75 bits/char), the optimization lever is representation, not compression.

    Solution: Indexed-palette PNG, compress_level=0

    Benefits:

    • Indexed mode: 1 byte/pixel vs 3-4 bytes/pixel for RGB
    • Payload size: 5.6× smaller than RGB cl=0
    • Shannon entropy: ~0.6-0.8 bits/char (~7× safety margin below threshold)
    • 256×256 thumbnail: ~47 KB base64 (acceptable for MCP protocol)

    Supported content:

    • ✅ Charts, infographics, diagrams
    • ✅ UI mockups, wireframes
    • ✅ QR codes, logos, icons
    • ❌ Photos/natural images: 5.77 bits/char even at palette cl=0 (blocked on Anthropic fix)

    Test Results Summary

    Encoding Entropy (bits/char) Result
    RGB cl=0 0.147 (chart) ✅ Pass
    Palette cl=0 ~0.6-0.8 (chart) ✅ Pass
    PNG cl=1 5.750 ❌ Fail
    PNG cl=9 5.236 ❌ Fail
    WebP lossless 5.393 ❌ Fail
    Palette cl=0 (photo) 5.77 ❌ Fail

    Threshold location: Between 4.48 and 4.56 bits/char
    Best estimate: 4.52 ± 0.04 (consistent with 4.5 exactly)


    Scope Limitation

    Natural images (photos, AI-generated art) remain blocked even with optimal representation. The inherent pixel variation in natural imagery produces entropy above threshold regardless of encoding.

    Recommendation: Anthropic should exempt type: "image" content blocks with mimeType from high-entropy redaction. These are already typed as image data, not potential secrets requiring heuristic detection.


    Implementation Status

    • ✅ Threshold measured via systematic bisection
    • ✅ Filter mechanism confirmed (global Shannon entropy)
    • ✅ Production workaround validated (palette PNG, 5.6× size reduction)
    • ✅ Entropy monitoring added to production path (drift detection)
    • ⏳ Awaiting Anthropic fix for unrestricted image delivery

    Repository: https://github.com/PeeperFrog/noisk-ai-mcp (Test 7 series, commits 3cbf27ef-416dfed9)

  5. earfman commented on Jul 18, 2026

    @earfman

    Corroborating from the SDK side — I audited the python-sdk source for anything that could alter content-block data:

    • There is no entropy-based redaction or sanitization anywhere in this SDK. The only base64-wrapping code (the =?base64?...?= sentinel in src/mcp/shared/inbound.py) applies to HTTP header round-trips, not content blocks.
    • The Image helper encodes with newline-free base64.b64encode — so the SDK emitting clean base64 matches your wire logs exactly.

    One additional fingerprint supporting your diagnosis: your measured threshold of 4.52 ± 0.04 bits/char is a near-exact match for the default base64 entropy limit in detect-secrets (4.5) — the most widely deployed secrets-scanning heuristic. That strongly suggests a stock secrets-redaction pass running over tool results downstream of this SDK, consistent with everything you measured.

    +1 to your recommendation: content blocks already typed "type": "image" with a mimeType shouldn't be subject to entropy-based secret heuristics at all. This likely belongs on Anthropic's Claude Code / claude.ai feedback tracker rather than this repo — but leaving the corroboration here so the next reader doesn't re-derive it.

  6. maxisbey commented on Aug 10, 2026

    @maxisbey
    Contributor

    There's no size- or entropy-based redaction in this SDK — ImageContent.data is a plain str written to the transport untouched — so the alteration is downstream in the client's tool-result ingestion; for Claude Code that's https://github.com/anthropics/claude-code. Closing this as part of a wider backlog cleanup following the v2 launch. Feel free to reopen if this is still relevant.

    AI Disclaimer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions