Skip to content

Commit 7167940

Browse files
authored
Merge pull request #5453 from modelcontextprotocol/v2/feat/5370-fetch-response-byte-cap
feat(fetch): cap the response body size while reading it
2 parents acb60ad + 5ff81de commit 7167940

4 files changed

Lines changed: 647 additions & 39 deletions

File tree

‎src/fetch/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,16 @@ The markdown of an HTML page opens with the page's title as a `#` heading (its `
3636

3737
### Errors
3838

39-
- A failed fetch (a robots.txt refusal, an unreachable page, an HTTP error status, a refused private address, an unusable proxy setting) and arguments that do not match the tool's schema are returned as a tool result with `isError: true`, whose text starts `Error executing tool fetch:` and says what went wrong, so the model can read it and decide what to do.
39+
- A failed fetch (a robots.txt refusal, an unreachable page, an HTTP error status, a refused private address, an unusable proxy setting, a response body over the [size limit](#response-size)) and arguments that do not match the tool's schema are returned as a tool result with `isError: true`, whose text starts `Error executing tool fetch:` and says what went wrong, so the model can read it and decide what to do.
4040
- Calling a tool the server does not have is a JSON-RPC protocol error, `-32602` (`Unknown tool: <name>`), as the specification requires. So is getting a prompt it does not have (`Unknown prompt: <name>`), or the `fetch` prompt without its `url` (`URL is required`) or with a `url` that is not a valid URL (`Invalid URL '<url>': <reason>`).
4141
- When the `fetch` prompt's fetch fails, the prompt still returns: its message holds the reason the fetch failed.
4242

4343
The server is built on the MCP Python SDK's high-level `MCPServer`: the tool and the prompt are decorated functions, and their type hints and field descriptions are the input schema and the prompt's arguments.
4444

45+
### Response size
46+
47+
The server reads at most 5 MiB (5,242,880 bytes) of a response body, so a very large or endless response cannot exhaust its memory. The limit is fixed and counts the body after its content encoding is decoded, and decoding itself stops at the limit, so a small compressed body that expands enormously is stopped without being expanded. The server asks for, and decodes, only `gzip` and `deflate` (`Accept-Encoding: gzip, deflate`); a response in any other content encoding (`br`, `zstd`, or more than one encoding applied in turn) is refused with an error. A page whose `Content-Length` is over it is refused before any of the body is read; a page that streams past it is refused once it does. Either way the tool returns an error saying so (and the `fetch` prompt returns that message), rather than part of the page. A robots.txt over the limit is not refused: its rules are read from the first 5 MiB. Each redirect is followed without reading the redirect response's own body. `max_length` and `start_index` page through what was read, so they are not affected by this limit and do not raise it.
48+
4549
### Content negotiation
4650

4751
The tool and the prompt ask for markdown first, with the request header `Accept: text/markdown, text/html;q=0.9, */*;q=0.8`. A site that serves markdown through content negotiation sends it directly, and a `text/markdown` response is returned as-is: it is not simplified and carries no raw-content notice. A site that ignores the header sends what it always did, and HTML is simplified to markdown as before. A `raw` fetch keeps the default `Accept: */*`, so it gets whatever the site serves by default (for a web page, its HTML), unsimplified; a markdown response it receives anyway is returned as-is too.

‎src/fetch/src/mcp_server_fetch/server.py‎

Lines changed: 221 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
# URL and convert HTML to Markdown for a model. The network policy lives here
33
# beside the handlers because every request path (tool, prompt and robots.txt
44
# check) must go through it: the private-address guard on each request and
5-
# redirect hop, robots.txt for autonomous fetches, and proxy normalization.
5+
# redirect hop, robots.txt for autonomous fetches, the cap on how much of a
6+
# response body is read (#5370), and proxy normalization.
67
#
78
# Built on the MCP Python SDK's high-level `MCPServer` (#5174), as the example
89
# to follow: the tool and the prompt are plain decorated functions, whose type
@@ -25,7 +26,9 @@
2526
import ipaddress
2627
import os
2728
import socket
28-
from collections.abc import Awaitable, Callable, Mapping, Sequence
29+
import zlib
30+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
31+
from contextlib import asynccontextmanager
2932
from typing import Annotated, Any, Tuple
3033
from importlib.metadata import version
3134
from urllib.parse import urlparse, urlunparse
@@ -397,7 +400,179 @@ def make_client(proxy_url: str | None) -> httpx.AsyncClient:
397400
)
398401

399402

400-
def decode_page(response: httpx.Response) -> str:
403+
# The most body bytes the server reads from one response (#5370). A body is
404+
# streamed and the read stops at this cap, so a very large or endless response
405+
# cannot make the server buffer all of it. `max_length` cannot do this: it
406+
# limits what is returned, after the whole page has been read and converted.
407+
# The cap counts decoded bytes, so a small compressed body that expands
408+
# enormously is stopped too, and decoding itself is bounded by it (see
409+
# `read_capped`).
410+
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
411+
412+
# The content codings the server asks for, and the only ones it decodes. They
413+
# are named explicitly because httpx would also offer br and zstd whenever
414+
# their packages happen to be installed, and decode them with no output limit.
415+
ACCEPT_ENCODING = "gzip, deflate"
416+
417+
418+
class BoundedInflater:
419+
"""Incremental gzip or deflate decoding whose output per call is bounded.
420+
421+
httpx's decoders inflate each network chunk whole, so one small chunk of a
422+
compression bomb could allocate far past the cap before any check saw it
423+
(#5370). zlib's ``max_length`` stops each call at the room left instead,
424+
and keeps the input it did not get to in ``unconsumed_tail``.
425+
"""
426+
427+
def __init__(self, coding: str) -> None:
428+
self._gzip = coding in ("gzip", "x-gzip")
429+
self._first = True
430+
self._z = zlib.decompressobj(
431+
zlib.MAX_WBITS | 16 if self._gzip else zlib.MAX_WBITS
432+
)
433+
434+
@property
435+
def unconsumed_tail(self) -> bytes:
436+
return self._z.unconsumed_tail
437+
438+
@property
439+
def eof(self) -> bool:
440+
"""Whether the compressed stream has ended."""
441+
return self._z.eof
442+
443+
def decompress(self, data: bytes, max_length: int) -> bytes:
444+
try:
445+
out = self._z.decompress(data, max_length)
446+
except zlib.error as e:
447+
if self._first and not self._gzip:
448+
# Servers often send deflate raw, without zlib's wrapper; httpx
449+
# retries the same way.
450+
self._first = False
451+
self._z = zlib.decompressobj(-zlib.MAX_WBITS)
452+
return self.decompress(data, max_length)
453+
raise httpx.DecodingError(f"Error decoding the response body: {e}")
454+
self._first = False
455+
return out
456+
457+
def flush(self) -> bytes:
458+
return self._z.flush()
459+
460+
461+
def inflater_for(response: httpx.Response) -> BoundedInflater | None:
462+
"""The decoder a response's Content-Encoding needs, or None for none.
463+
464+
Only a single gzip or deflate coding is decoded. Any other (br, zstd, a
465+
chain of codings) is refused rather than handed over undecoded or decoded
466+
without a bound.
467+
"""
468+
codings = [
469+
coding.strip().lower()
470+
for coding in response.headers.get("content-encoding", "").split(",")
471+
]
472+
codings = [coding for coding in codings if coding not in ("", "identity")]
473+
if not codings:
474+
return None
475+
if len(codings) == 1 and codings[0] in ("gzip", "x-gzip", "deflate"):
476+
return BoundedInflater(codings[0])
477+
raise httpx.DecodingError(
478+
f"Unsupported content encoding {', '.join(codings)}: this server "
479+
f"decodes only {ACCEPT_ENCODING}"
480+
)
481+
482+
483+
@asynccontextmanager
484+
async def stream_get(
485+
client: httpx.AsyncClient, url: str, user_agent: str, **kwargs: Any
486+
) -> AsyncIterator[httpx.Response]:
487+
"""GET ``url`` with redirects followed, yielding the final response unread.
488+
489+
httpx's own redirect handling reads each redirect's body whole before it
490+
follows it, so a redirect with an endless body would get round the cap.
491+
Here each hop is closed unread instead. The request hooks still run before
492+
every hop, since they run on each ``send``. ``kwargs`` go to
493+
``build_request`` (the timeout).
494+
"""
495+
request = client.build_request(
496+
"GET",
497+
url,
498+
headers={"User-Agent": user_agent, "Accept-Encoding": ACCEPT_ENCODING},
499+
**kwargs,
500+
)
501+
response = await client.send(request, stream=True, follow_redirects=False)
502+
try:
503+
hops = 0
504+
while response.next_request is not None:
505+
hops += 1
506+
if hops > client.max_redirects:
507+
raise httpx.TooManyRedirects(
508+
"Exceeded maximum allowed redirects.",
509+
request=response.next_request,
510+
)
511+
next_request = response.next_request
512+
await response.aclose()
513+
response = await client.send(
514+
next_request, stream=True, follow_redirects=False
515+
)
516+
yield response
517+
finally:
518+
await response.aclose()
519+
520+
521+
async def read_capped(response: httpx.Response, limit: int) -> tuple[bytes, bool]:
522+
"""Read a streamed response's body, stopping once it passes ``limit`` bytes.
523+
524+
Returns the body, at most ``limit`` bytes of it, and whether it was cut
525+
short. Whatever the server has not sent yet is never read: closing the
526+
response (leaving `stream_get`) closes the connection. The raw bytes are
527+
decoded here rather than by httpx, so no single step produces more than
528+
one byte past the room left under ``limit`` (see `BoundedInflater`).
529+
"""
530+
inflater = inflater_for(response)
531+
body = bytearray()
532+
async for raw in response.aiter_raw():
533+
pending = raw
534+
while pending:
535+
room = limit - len(body)
536+
if inflater is None:
537+
out, pending = pending[: room + 1], b""
538+
else:
539+
out = inflater.decompress(pending, room + 1)
540+
pending = inflater.unconsumed_tail
541+
body += out
542+
if len(body) > limit:
543+
return bytes(body[:limit]), True
544+
if inflater is not None and inflater.eof:
545+
# The compressed stream is complete. Whatever follows it is not
546+
# part of the body (httpx's decoders drop it too), and zlib
547+
# would keep all of it in `unused_data`, so reading stops here.
548+
return bytes(body), False
549+
if inflater is not None:
550+
# All input is consumed by now, so zlib has at most a few bytes left.
551+
body += inflater.flush()
552+
if len(body) > limit:
553+
return bytes(body[:limit]), True
554+
return bytes(body), False
555+
556+
557+
def declared_length(response: httpx.Response) -> int | None:
558+
"""The body size a response's Content-Length declares, or None without one."""
559+
try:
560+
return int(response.headers["content-length"])
561+
except (KeyError, ValueError):
562+
return None
563+
564+
565+
def decode_text(response: httpx.Response, content: bytes) -> str:
566+
"""Decode ``content`` as httpx's ``response.text`` would.
567+
568+
The header's charset when Python knows it, UTF-8 otherwise, with undecodable
569+
bytes replaced. A streamed response has no ``.text`` until its whole body is
570+
read through httpx, which is the read the cap exists to avoid.
571+
"""
572+
return content.decode(response.encoding or "utf-8", errors="replace")
573+
574+
575+
def decode_page(response: httpx.Response, content: bytes) -> str:
401576
"""Decode a fetched body, honoring a charset the HTML declares (#5199).
402577
403578
httpx's ``response.text`` uses the Content-Type header's charset, and
@@ -413,13 +588,15 @@ def decode_page(response: httpx.Response) -> str:
413588
unrelated attribute (``data-charset``) is not mistaken for a declaration.
414589
A declaration Python does not know, or one that names a codec that is not
415590
a web text encoding, is ignored, and the body is decoded as before.
591+
592+
``content`` is the body as read by `read_capped`; the response supplies
593+
the headers.
416594
"""
417595
# Imported here so the helper stays one self-contained block of this file.
418596
import codecs
419597
import re
420598
from html.parser import HTMLParser
421599

422-
content = response.content
423600
media_type = (
424601
response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
425602
)
@@ -430,7 +607,7 @@ def decode_page(response: httpx.Response) -> str:
430607
(codecs.BOM_UTF8, codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)
431608
)
432609
):
433-
return response.text
610+
return decode_text(response, content)
434611

435612
class MetaCharset(HTMLParser):
436613
declared: str | None = None
@@ -458,7 +635,7 @@ def handle_starttag(
458635
scan = MetaCharset()
459636
scan.feed(content[:1024].decode("latin-1"))
460637
if scan.declared is None:
461-
return response.text
638+
return decode_text(response, content)
462639
try:
463640
name = codecs.lookup(scan.declared).name
464641
# A UTF-16/32 declaration in an ASCII-compatible document cannot be
@@ -468,20 +645,20 @@ def handle_starttag(
468645
# Python codecs that are not web text encodings: decoding a page with
469646
# them would mangle it rather than fix it.
470647
if name in ("idna", "punycode", "unicode-escape", "raw-unicode-escape"):
471-
return response.text
648+
return decode_text(response, content)
472649
# A declaration found in ASCII bytes is only believable for an
473650
# ASCII-compatible encoding. UTF-7, HZ and the EBCDIC code pages
474651
# (cp037, cp500, …) would rewrite the markup itself, so they fail this
475652
# probe (by a different result or a decode error) and are ignored.
476653
probe = b"\t\n\r" + bytes(range(0x20, 0x7F))
477654
if probe.decode(name) != probe.decode("ascii"):
478-
return response.text
655+
return decode_text(response, content)
479656
return content.decode(name, errors="replace")
480657
except (LookupError, ValueError): # UnicodeError is a ValueError
481658
# An unknown charset, a name Python refuses outright (one holding a
482659
# NUL), a non-text codec (base64, rot13, …), one that refuses to
483660
# decode at all (``undefined``), or one the probe cannot decode.
484-
return response.text
661+
return decode_text(response, content)
485662

486663

487664
async def check_may_autonomously_fetch_url(
@@ -507,24 +684,25 @@ async def check_may_autonomously_fetch_url(
507684
hooks.append(_refuse_private_destination)
508685
client.event_hooks = {"request": hooks}
509686
try:
510-
response = await client.get(
511-
robot_txt_url,
512-
follow_redirects=True,
513-
headers={"User-Agent": user_agent},
514-
)
687+
async with stream_get(client, robot_txt_url, user_agent) as response:
688+
# A 5xx robots.txt is "unreachable" (RFC 9309 section 2.3.1.4),
689+
# which means complete disallow: the error page is never parsed
690+
# as rules.
691+
if response.status_code in (401, 403) or response.status_code >= 500:
692+
raise ToolError(
693+
f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt"
694+
)
695+
elif 400 <= response.status_code < 500:
696+
return
697+
# Read up to the same cap as a page (#5370). RFC 9309 section
698+
# 2.5 lets a crawler stop parsing past a limit of at least
699+
# 500 KiB, so the rules in the first part are used as they are.
700+
content, _ = await read_capped(response, MAX_RESPONSE_BYTES)
515701
except HTTPError:
516702
raise ToolError(
517703
f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue"
518704
)
519-
# A 5xx robots.txt is "unreachable" (RFC 9309 section 2.3.1.4), which
520-
# means complete disallow: the error page is never parsed as rules.
521-
if response.status_code in (401, 403) or response.status_code >= 500:
522-
raise ToolError(
523-
f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt"
524-
)
525-
elif 400 <= response.status_code < 500:
526-
return
527-
robot_txt = response.text
705+
robot_txt = decode_text(response, content)
528706
processed_robot_txt = "\n".join(
529707
line for line in robot_txt.splitlines() if not line.strip().startswith("#")
530708
)
@@ -609,25 +787,34 @@ async def _check_redirect_robots_txt(request: httpx.Request) -> None:
609787
# default representation (a page's HTML), as before this negotiation.
610788
if not force_raw:
611789
client.headers["Accept"] = MARKDOWN_ACCEPT
790+
too_large = (
791+
f"Failed to fetch {url}: the response body is larger than the "
792+
f"{MAX_RESPONSE_BYTES} byte limit this server reads"
793+
)
612794
try:
613-
response = await client.get(
614-
url,
615-
follow_redirects=True,
616-
headers={"User-Agent": user_agent},
617-
timeout=timeout_ms / 1000,
618-
)
795+
async with stream_get(
796+
client, url, user_agent, timeout=timeout_ms / 1000
797+
) as response:
798+
if response.status_code >= 400:
799+
raise ToolError(
800+
f"Failed to fetch {url} - status code {response.status_code}"
801+
)
802+
# Refused before reading anything when the size is declared;
803+
# otherwise the streamed read stops at the cap (#5370).
804+
length = declared_length(response)
805+
if length is not None and length > MAX_RESPONSE_BYTES:
806+
raise ToolError(f"{too_large} (it declares {length} bytes)")
807+
content, truncated = await read_capped(response, MAX_RESPONSE_BYTES)
808+
if truncated:
809+
raise ToolError(too_large)
619810
except httpx.TimeoutException as e:
620811
raise ToolError(
621812
f"Failed to fetch {url}: timed out after {timeout_ms} ms: {e!r}"
622813
)
623814
except HTTPError as e:
624815
raise ToolError(f"Failed to fetch {url}: {e!r}")
625-
if response.status_code >= 400:
626-
raise ToolError(
627-
f"Failed to fetch {url} - status code {response.status_code}"
628-
)
629816

630-
page_raw = decode_page(response)
817+
page_raw = decode_page(response, content)
631818

632819
content_type = response.headers.get("content-type", "")
633820
# Markdown is already what this tool returns, so it is not simplified, and

‎src/fetch/tests/harness.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,21 @@ def handle(self, request: httpx.Request) -> httpx.Response:
6666
self.requests.append(request)
6767
responder = self.routes.get(str(request.url))
6868
if responder is None:
69-
return httpx.Response(404, content=b"no route in FakeWeb")
70-
if isinstance(responder, httpx.Response):
71-
return responder
72-
return responder(request)
69+
response = httpx.Response(404, content=b"no route in FakeWeb")
70+
elif isinstance(responder, httpx.Response):
71+
response = responder
72+
else:
73+
response = responder(request)
74+
# Served unread, as from the network. `httpx.Response(content=...)`
75+
# reads (and decodes) its body when it is built, which leaves nothing
76+
# for the server's raw, capped read (#5370); its stream still holds the
77+
# bytes as given, so a fresh response over that stream is the wire form.
78+
return httpx.Response(
79+
response.status_code,
80+
headers=response.headers,
81+
stream=response.stream,
82+
extensions=response.extensions,
83+
)
7384

7485
def urls(self) -> list[str]:
7586
return [str(r.url) for r in self.requests]

0 commit comments

Comments
 (0)