A tool adapter accepts a gzip result because the compressed body is below its 64 KiB limit. It then decompresses the body and hands the result to a document parser. The size check passed, but the parser receives 2 MiB.
You can reproduce this with 2,068 compressed bytes in the downloadable fixture. This is a synthetic example, not a reported vulnerability in Claude Code or an MCP server. The small payload is enough to demonstrate why a transport-size check cannot enforce a decoded-output budget.
I would put the output limit inside the decompression boundary, before the adapter allocates the complete expanded body. Reject an oversized result as a whole. Passing its first few paragraphs to an agent creates a second problem: the agent may treat an incomplete document as complete evidence.
Run the size-check counterexample
Download the Python fixture, inspect it, and run:
python3 agent-gzip-output-limit.py
python3 agent-gzip-output-limit.py --test
python3 -O agent-gzip-output-limit.py --test
The fixture uses Python’s standard library, makes no network calls and runs no model. The demonstration deliberately allocates a 2 MiB repeated-letter payload, compresses it and compares an unbounded decode with a bounded one. It does not generate a large archive or attempt to exhaust the host’s memory.
The local execution produced:
compressed bytes: 2068
compressed check: PASS
naive decoded bytes: 2097152
bounded decoder: decoded output limit exceeded
The compressed size can vary with the compressor implementation. The tests require it to fit below the input limit; they do not require that particular compressed byte count.
The faulty implementation even appears to supply an output limit:
body = zlib.decompress(blob, wbits=31, bufsize=256 * 1024)
But zlib.decompress documents bufsize as the initial buffer size. Python increases the buffer if it needs more space. In the fixture, the call returns all 2,097,152 bytes despite the 262,144-byte bufsize. Giving a parameter the value of your budget does not make that parameter an enforcement control.
OWASP’s file-upload guidance also calls for considering file size after decompression. Applying that principle to fetched documents and compressed tool results is an adapter design decision. It should happen before parsing, embedding or adding text to model context.
Limit the returned output at the decoder
The fixture accepts one already-buffered gzip member. It first checks the compressed bytes, then uses a decompression object whose decompress method has a different contract from the module-level function:
decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
body = decoder.decompress(blob, output_limit + 1)
if len(body) > output_limit:
raise Rejected('decoded output limit exceeded')
if not decoder.eof:
raise Rejected('incomplete gzip stream')
if decoder.unused_data:
raise Rejected('trailing bytes or additional gzip member')
return body
This is the core excerpt; the download includes input validation and exception handling. The gzip-specific wbits setting avoids silently accepting a different wrapper format.
Python documents nonzero max_length as a cap on the returned bytes. The fixture asks for the permitted output plus one byte. That extra byte distinguishes a body exactly at the limit from one that exceeds it. The oversized body is rejected before the function returns data to its caller.
Zero deserves a test of its own. For this API, max_length=0 means decompress the whole input, not “allow no output”. The helper therefore requires positive integer budgets and rejects booleans and floating-point values. A production configuration layer should also impose deployment-specific maximums; this teaching helper does not decide how much memory your worker can afford.
Do not add decoder.flush() as a convenient last step. Its length parameter is another initial buffer size, not the same hard returned-output cap. This helper supplies the entire bounded input in one call and requires a completed stream within the allowance. It has no need to flush an incomplete stream into a larger allocation.
Completion is part of acceptance
A decompressor can produce useful-looking text before it has received the complete stream. Returning that text early would let a truncated trailer pass unnoticed. The eof attribute lets the caller distinguish a completed compressed stream from an incomplete one. The fixture also converts a zlib error, including the tested checksum corruption, into rejection.
This function deliberately rejects a second gzip member and any trailing bytes. That is a narrow application contract, not a claim that every concatenated gzip file is invalid. If your tool is supposed to return multiple members, implement that format explicitly and charge every member against one cumulative output budget. Resetting the allowance for each member defeats the total-result limit.
For a streaming implementation, keep the same cumulative accounting across chunks. Python exposes unconsumed_tail for compressed data not processed because the output cap was reached. Feed it back only while allowance remains. Recreating the decoder or granting a fresh output budget per chunk changes the contract. The download intentionally avoids that additional state machine by accepting a bounded bytes object.
The fourteen unit tests cover ordinary and empty members, the exact output boundary, one byte over the boundary, and the highly compressible example. They also check the compressed-input boundary, truncated trailers, checksum corruption, non-gzip input, concatenated members, trailing garbage, invalid budgets, the required input type and the misleading bufsize call. The suite passes with Python optimisation enabled because rejection does not depend on removable assert statements.
These are component tests. They do not establish that an HTTP client, an MCP transport or a document-ingestion service applies these checks in the right place.
Put the helper before automatic expansion
The function receives an existing bytes object. Its input check cannot undo memory already allocated by the caller. A real fetch adapter needs a bounded transport reader before this function, and you must establish whether the client delivers compressed bytes or has already decompressed them.
If the client automatically expands the response before your callback runs, a check in that callback is too late to control that allocation. Configure the appropriate raw-body interface or enforce limits in the client’s supported decoding path. Verify the exact library and version with a local test response; do not assume a method named stream exposes compressed bytes.
Decoded bytes are only one resource. The output cap does not bound total process memory: compressed input, decoder state, returned bytes and subsequent parser objects also occupy memory. Nor does it establish a CPU-time limit. A worker still needs a shared elapsed-time budget and an enforceable resource boundary where hostile files are processed.
After decompression, apply the parser’s own constraints. A small JSON body can still require a nesting-depth policy. A document within the byte limit may still contain more pages or extracted text than the task permits. Each transformation needs a defined unit and an enforced limit at that boundary.
Keep rejection out of the evidence channel
When the result exceeds its budget, the adapter should return a structured failure that the agent can distinguish from document content. Record the configured limit, rejection stage and whether the document was accepted. Avoid logging the partial decoded body merely to explain the error.
If the workflow needs a preview, make that a separate tool result with an explicit incomplete status and permitted uses. A preview cannot support a claim that the entire document was searched. The same completeness problem appears when an agent reads one inventory page and plans for the whole collection; check pagination and decompression boundaries separately.
For the broader operating workflow, see my Claude Code book, available as a Kindle edition on Amazon UK. This downloadable fixture is a separate teaching example.
Before enabling compressed results on a tool adapter, run an exact-limit body, a one-byte-over body and a truncated trailer through the actual client. Confirm that none of the rejected output reaches the parser, cache or model context.