Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/fetch/src/mcp_server_fetch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,11 @@ class Fetch(BaseModel):
Field(
default=5000,
description="Maximum number of characters to return.",
gt=0,
lt=1000000,
# ge/le rather than gt/lt: the inclusive bounds emit minimum/maximum,
# which every client accepts, while exclusiveMinimum/exclusiveMaximum
# are rejected by some (Gemini, #1624). Same range for an int.
ge=1,
le=999999,
),
]
start_index: Annotated[
Expand Down
24 changes: 18 additions & 6 deletions src/fetch/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ async def test_server_version_is_the_sdk_version(web: FakeWeb) -> None:
# --------------------------------------------------------------------------


# KNOWN BUG #1624: pins current (wrong) behavior; the fix changes this assertion.
async def test_list_tools_wire_shape(web: FakeWeb) -> None:
async with connect() as (session, _):
result = await session.list_tools()
Expand All @@ -129,13 +128,13 @@ async def test_list_tools_wire_shape(web: FakeWeb) -> None:
"title": "Url",
"type": "string",
},
# #1624: pydantic's gt/lt emit the numeric (draft 6+) form of
# exclusiveMinimum/exclusiveMaximum, which some clients reject.
# #1624: inclusive minimum/maximum, never exclusiveMinimum/
# exclusiveMaximum, which some clients (Gemini) reject.
"max_length": {
"default": 5000,
"description": "Maximum number of characters to return.",
"exclusiveMaximum": 1000000,
"exclusiveMinimum": 0,
"maximum": 999999,
"minimum": 1,
"title": "Max Length",
"type": "integer",
},
Expand Down Expand Up @@ -184,7 +183,8 @@ async def test_call_with_only_url_applies_defaults(web: FakeWeb) -> None:
({}, "'url'"),
({"url": ""}, "''"),
({"url": 5}, "5"),
# #1624: the bounds come from exclusiveMinimum/exclusiveMaximum.
# #1624: the bounds are minimum 1 and maximum 999999, so the rejected
# values are the same as under the old exclusive 0 and 1000000.
({"url": PAGE, "max_length": 0}, "0"),
({"url": PAGE, "max_length": 1000000}, "1000000"),
({"url": PAGE, "start_index": -1}, "-1"),
Expand All @@ -205,6 +205,18 @@ async def test_schema_violations_are_rejected_by_the_sdk(
assert web.requests == []


@pytest.mark.parametrize("max_length", [1, 999999])
async def test_max_length_bounds_are_inclusive(web: FakeWeb, max_length: int) -> None:
# #1624: the edges of the inclusive range pass both the SDK's schema check
# and Fetch's own validation, and the page is fetched.
web.add(ROBOTS, plain("", status=404))
web.add(PAGE, plain("hello"))
async with connect() as (session, _):
result = await call(session, {"url": PAGE, "max_length": max_length})
assert wire(result)["isError"] is False
assert web.urls() == [ROBOTS, PAGE]


@pytest.mark.parametrize("url", ["not a url", "example.com/page", "http://"])
async def test_invalid_url_is_rejected_by_pydantic(web: FakeWeb, url: str) -> None:
# jsonschema does not check `format: uri`, so these reach Fetch(**arguments)
Expand Down
Loading