Skip to content

Commit 15c8b0c

Browse files
cliffhallclaude
andcommitted
Port fetch, git and time to the MCP Python SDK v2
Move the three Python servers from mcp 1.x to mcp>=2.2,<3 and drop the mcp>=1.29.0,<2 pin. Each stays on the low-level Server, now built with on_* constructor handlers instead of decorators, and returns ListToolsResult / ListPromptsResult / CallToolResult explicitly. SDK v2's low-level server no longer validates tool arguments against inputSchema or folds a handler exception into an isError result. Each server's call_tool now does both itself, with v1's messages, so tool results on the wire are unchanged. jsonschema becomes a direct dependency for that. McpError(ErrorData(...)) becomes MCPError(code=..., message=...), and Tool / ToolAnnotations fields move to snake_case. Tests: only import and type changes, plus wire() dumping with exclude_unset (v2 result models default fields the 2025-11-25 wire does not carry). One assertion changes: v2 omits the empty "experimental": {} capability, in git and time. Closes #4851 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent b5cea9d commit 15c8b0c

19 files changed

Lines changed: 666 additions & 512 deletions

File tree

‎src/fetch/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A Model Context Protocol server that provides web content fetching capabilities.
66

77
Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch
88

9-
Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress.
9+
Requires MCP Python SDK 2.x (`mcp>=2.2,<3`).
1010

1111
> [!CAUTION]
1212
> This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data.

‎src/fetch/pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ classifiers = [
1818
dependencies = [
1919
"httpx[socks]>=0.27",
2020
"markdownify>=0.13.1",
21-
"mcp>=1.29.0,<2",
21+
"jsonschema>=4.20.0",
22+
"mcp>=2.2,<3",
2223
"protego>=0.3.1",
2324
"pydantic>=2.0.0",
2425
"readabilipy>=0.2.0",

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

Lines changed: 109 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,26 @@
22
import ipaddress
33
import os
44
import socket
5-
from typing import Annotated, Tuple
5+
from typing import Annotated, Any, Tuple
66
from importlib.metadata import version
77
from urllib.parse import urlparse, urlunparse
88

99
import httpx
10+
import jsonschema
1011
import markdownify
1112
import readabilipy.simple_json
12-
from mcp.shared.exceptions import McpError
13-
from mcp.server import Server
13+
from mcp.shared.exceptions import MCPError
14+
from mcp.server import Server, ServerRequestContext
1415
from mcp.server.stdio import stdio_server
1516
from mcp.types import (
16-
ErrorData,
17+
CallToolRequestParams,
18+
CallToolResult,
19+
ContentBlock,
20+
GetPromptRequestParams,
1721
GetPromptResult,
22+
ListPromptsResult,
23+
ListToolsResult,
24+
PaginatedRequestParams,
1825
Prompt,
1926
PromptArgument,
2027
PromptMessage,
@@ -132,13 +139,11 @@ async def _refuse_private_destination(request: httpx.Request) -> None:
132139
return
133140
for address in addresses:
134141
if not _is_public_address(address):
135-
raise McpError(
136-
ErrorData(
137-
code=INVALID_PARAMS,
138-
message=f"Refused to fetch {request.url}: {host} resolves to {address}, "
139-
"which is not a public address. Start the server with "
140-
"--allow-private-ips to allow private, loopback and link-local addresses.",
141-
)
142+
raise MCPError(
143+
code=INVALID_PARAMS,
144+
message=f"Refused to fetch {request.url}: {host} resolves to {address}, "
145+
"which is not a public address. Start the server with "
146+
"--allow-private-ips to allow private, loopback and link-local addresses.",
142147
)
143148

144149

@@ -227,13 +232,11 @@ def make_client(proxy_url: str | None) -> httpx.AsyncClient:
227232
try:
228233
return httpx.AsyncClient(proxy=proxy_url)
229234
except (ValueError, ImportError) as e:
230-
raise McpError(
231-
ErrorData(
232-
code=INTERNAL_ERROR,
233-
message=f"Failed to set up the HTTP client, check the proxy "
234-
f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and "
235-
f"ALL_PROXY environment variables): {e}",
236-
)
235+
raise MCPError(
236+
code=INTERNAL_ERROR,
237+
message=f"Failed to set up the HTTP client, check the proxy "
238+
f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and "
239+
f"ALL_PROXY environment variables): {e}",
237240
)
238241

239242

@@ -245,7 +248,7 @@ async def check_may_autonomously_fetch_url(
245248
) -> None:
246249
"""
247250
Check if the URL can be fetched by the user agent according to the robots.txt file.
248-
Raises a McpError if not.
251+
Raises an MCPError if not.
249252
"""
250253
from httpx import HTTPError
251254

@@ -261,18 +264,14 @@ async def check_may_autonomously_fetch_url(
261264
headers={"User-Agent": user_agent},
262265
)
263266
except HTTPError:
264-
raise McpError(
265-
ErrorData(
266-
code=INTERNAL_ERROR,
267-
message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue",
268-
)
267+
raise MCPError(
268+
code=INTERNAL_ERROR,
269+
message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue",
269270
)
270271
if response.status_code in (401, 403):
271-
raise McpError(
272-
ErrorData(
273-
code=INTERNAL_ERROR,
274-
message=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",
275-
)
272+
raise MCPError(
273+
code=INTERNAL_ERROR,
274+
message=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",
276275
)
277276
elif 400 <= response.status_code < 500:
278277
return
@@ -282,16 +281,14 @@ async def check_may_autonomously_fetch_url(
282281
)
283282
robot_parser = Protego.parse(processed_robot_txt)
284283
if not robot_parser.can_fetch(str(url), user_agent):
285-
raise McpError(
286-
ErrorData(
287-
code=INTERNAL_ERROR,
288-
message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, "
289-
f"<useragent>{user_agent}</useragent>\n"
290-
f"<url>{url}</url>"
291-
f"<robots>\n{robot_txt}\n</robots>\n"
292-
f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n"
293-
f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.",
294-
)
284+
raise MCPError(
285+
code=INTERNAL_ERROR,
286+
message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, "
287+
f"<useragent>{user_agent}</useragent>\n"
288+
f"<url>{url}</url>"
289+
f"<robots>\n{robot_txt}\n</robots>\n"
290+
f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n"
291+
f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.",
295292
)
296293

297294

@@ -318,15 +315,11 @@ async def fetch_url(
318315
timeout=30,
319316
)
320317
except HTTPError as e:
321-
raise McpError(
322-
ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}")
323-
)
318+
raise MCPError(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}")
324319
if response.status_code >= 400:
325-
raise McpError(
326-
ErrorData(
327-
code=INTERNAL_ERROR,
328-
message=f"Failed to fetch {url} - status code {response.status_code}",
329-
)
320+
raise MCPError(
321+
code=INTERNAL_ERROR,
322+
message=f"Failed to fetch {url} - status code {response.status_code}",
330323
)
331324

332325
page_raw = response.text
@@ -378,6 +371,13 @@ class Fetch(BaseModel):
378371
]
379372

380373

374+
def tool_error(message: str) -> CallToolResult:
375+
"""A tool call that failed, as an `isError` result the model can read."""
376+
return CallToolResult(
377+
content=[TextContent(type="text", text=message)], is_error=True
378+
)
379+
380+
381381
async def serve(
382382
custom_user_agent: str | None = None,
383383
ignore_robots_txt: bool = False,
@@ -396,44 +396,46 @@ async def serve(
396396
if proxy_url:
397397
proxy_url = normalize_proxy_url(proxy_url)
398398
normalize_proxy_env()
399-
server = Server("mcp-fetch", version=SERVER_VERSION)
400399
user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS
401400
user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL
402401

403-
@server.list_tools()
404-
async def list_tools() -> list[Tool]:
405-
return [
406-
Tool(
407-
name="fetch",
408-
description="""Fetches a URL from the internet and optionally extracts its contents as markdown.
402+
tool = Tool(
403+
name="fetch",
404+
description="""Fetches a URL from the internet and optionally extracts its contents as markdown.
409405
410406
Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""",
411-
inputSchema=Fetch.model_json_schema(),
412-
)
413-
]
414-
415-
@server.list_prompts()
416-
async def list_prompts() -> list[Prompt]:
417-
return [
418-
Prompt(
419-
name="fetch",
420-
description="Fetch a URL and extract its contents as markdown",
421-
arguments=[
422-
PromptArgument(
423-
name="url", description="URL to fetch", required=True
424-
)
425-
],
426-
)
427-
]
407+
input_schema=Fetch.model_json_schema(),
408+
)
409+
410+
async def list_tools(
411+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
412+
) -> ListToolsResult:
413+
return ListToolsResult(tools=[tool])
414+
415+
async def list_prompts(
416+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
417+
) -> ListPromptsResult:
418+
return ListPromptsResult(
419+
prompts=[
420+
Prompt(
421+
name="fetch",
422+
description="Fetch a URL and extract its contents as markdown",
423+
arguments=[
424+
PromptArgument(
425+
name="url", description="URL to fetch", required=True
426+
)
427+
],
428+
)
429+
]
430+
)
428431

429-
@server.call_tool()
430-
async def call_tool(name, arguments: dict) -> list[TextContent]:
432+
async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]:
431433
if name != "fetch":
432434
raise ValueError(f"Unknown tool: {name}")
433435
try:
434436
args = Fetch(**arguments)
435437
except ValueError as e:
436-
raise McpError(ErrorData(code=INVALID_PARAMS, message=str(e)))
438+
raise MCPError(code=INVALID_PARAMS, message=str(e))
437439

438440
url = str(args.url)
439441

@@ -468,14 +470,31 @@ async def call_tool(name, arguments: dict) -> list[TextContent]:
468470
content += f"\n\n<error>Content truncated. Call the fetch tool with a start_index of {next_start} to get more content.</error>"
469471
return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")]
470472

471-
@server.get_prompt()
472-
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
473+
async def call_tool(
474+
ctx: ServerRequestContext, params: CallToolRequestParams
475+
) -> CallToolResult:
476+
# SDK v2's low-level server neither validates arguments nor turns a
477+
# handler exception into an isError result, both of which SDK v1 did.
478+
# Both are done here, with v1's messages, so the wire is unchanged.
479+
arguments = params.arguments or {}
480+
if params.name == tool.name:
481+
try:
482+
jsonschema.validate(instance=arguments, schema=tool.input_schema)
483+
except jsonschema.ValidationError as e:
484+
return tool_error(f"Input validation error: {e.message}")
485+
try:
486+
return CallToolResult(content=await run_tool(params.name, arguments))
487+
except Exception as e:
488+
return tool_error(str(e))
489+
490+
async def get_prompt(
491+
ctx: ServerRequestContext, params: GetPromptRequestParams
492+
) -> GetPromptResult:
493+
name, arguments = params.name, params.arguments
473494
if name != "fetch":
474-
raise McpError(
475-
ErrorData(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")
476-
)
495+
raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")
477496
if not arguments or "url" not in arguments:
478-
raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required"))
497+
raise MCPError(code=INVALID_PARAMS, message="URL is required")
479498

480499
url = arguments["url"]
481500

@@ -487,7 +506,7 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
487506
allow_private_ips=allow_private_ips,
488507
)
489508
# TODO: after SDK bug is addressed, don't catch the exception
490-
except McpError as e:
509+
except MCPError as e:
491510
return GetPromptResult(
492511
description=f"Failed to fetch {url}",
493512
messages=[
@@ -506,6 +525,15 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
506525
],
507526
)
508527

528+
server = Server(
529+
"mcp-fetch",
530+
version=SERVER_VERSION,
531+
on_list_tools=list_tools,
532+
on_list_prompts=list_prompts,
533+
on_call_tool=call_tool,
534+
on_get_prompt=get_prompt,
535+
)
536+
509537
options = server.create_initialization_options()
510538
async with stdio_server() as (read_stream, write_stream):
511539
await server.run(read_stream, write_stream, options, raise_exceptions=False)

‎src/fetch/tests/harness.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
- ``connect(...)`` runs the real ``serve()`` in-process and yields an
66
initialized ``ClientSession`` linked to it over in-memory streams, so tests
7-
go through the SDK's validation and error mapping exactly as a client would.
7+
go through the server's validation and error mapping exactly as a client would.
88
- ``web`` replaces ``httpx.AsyncClient`` with one backed by an
99
``httpx.MockTransport``, so no test touches the network. It records every
1010
request and the keyword arguments each client was constructed with. It also

‎src/fetch/tests/test_protocol.py‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import httpx
2323
import pytest
2424
from mcp import ClientSession
25-
from mcp.shared.exceptions import McpError
25+
from mcp.shared.exceptions import MCPError
2626
from mcp.types import INVALID_PARAMS, CallToolResult
2727

2828
from mcp_server_fetch.server import (
@@ -52,7 +52,12 @@
5252

5353

5454
def wire(model: Any) -> dict[str, Any]:
55-
return model.model_dump(by_alias=True, mode="json", exclude_none=True)
55+
# exclude_unset: SDK v2 result models default fields the 2025-11-25 wire
56+
# does not carry (resultType, ttlMs, cacheScope), so only what the server
57+
# actually sent is compared.
58+
return model.model_dump(
59+
by_alias=True, mode="json", exclude_none=True, exclude_unset=True
60+
)
5661

5762

5863
def text_of(result: CallToolResult) -> str:
@@ -193,7 +198,7 @@ async def test_call_with_only_url_applies_defaults(web: FakeWeb) -> None:
193198
async def test_schema_violations_are_rejected_by_the_sdk(
194199
web: FakeWeb, arguments: dict[str, Any], offending: str
195200
) -> None:
196-
# The SDK validates against inputSchema before the handler runs. The rest
201+
# call_tool validates against inputSchema before the tool runs. The rest
197202
# of the message is jsonschema's wording, which varies by version.
198203
async with connect() as (session, _):
199204
result = await call(session, arguments)
@@ -206,7 +211,7 @@ async def test_schema_violations_are_rejected_by_the_sdk(
206211

207212
@pytest.mark.parametrize("max_length", [1, 999999])
208213
async def test_max_length_bounds_are_inclusive(web: FakeWeb, max_length: int) -> None:
209-
# #1624: the edges of the inclusive range pass both the SDK's schema check
214+
# #1624: the edges of the inclusive range pass both the schema check
210215
# and Fetch's own validation, and the page is fetched.
211216
web.add(ROBOTS, plain("", status=404))
212217
web.add(PAGE, plain("hello"))
@@ -230,8 +235,8 @@ async def test_invalid_url_is_rejected_by_pydantic(web: FakeWeb, url: str) -> No
230235

231236

232237
async def test_unknown_tool_name_is_rejected_without_fetching(web: FakeWeb) -> None:
233-
# The SDK skips schema validation for a tool it has not listed, so the
234-
# name check in call_tool is what stops an unknown name from fetching.
238+
# call_tool skips schema validation for a tool it has not listed, so the
239+
# name check in run_tool is what stops an unknown name from fetching.
235240
web.add(ROBOTS, plain("", status=404))
236241
web.add(PAGE, plain("fetched anyway"))
237242
async with connect() as (session, _):
@@ -949,7 +954,7 @@ async def test_get_prompt_without_url_is_a_jsonrpc_error(
949954
web: FakeWeb, arguments: dict[str, str] | None
950955
) -> None:
951956
async with connect() as (session, _):
952-
with pytest.raises(McpError) as excinfo:
957+
with pytest.raises(MCPError) as excinfo:
953958
await session.get_prompt("fetch", arguments)
954959
assert wire(excinfo.value.error) == {
955960
"code": INVALID_PARAMS,
@@ -966,7 +971,7 @@ async def test_get_prompt_with_unknown_name_is_a_jsonrpc_error(
966971
# whether or not a URL was given, and nothing is fetched.
967972
web.add(PAGE, plain("ok"))
968973
async with connect() as (session, _):
969-
with pytest.raises(McpError) as excinfo:
974+
with pytest.raises(MCPError) as excinfo:
970975
await session.get_prompt("nope", arguments)
971976
assert wire(excinfo.value.error) == {
972977
"code": INVALID_PARAMS,

0 commit comments

Comments
 (0)