Skip to content

Commit 0763da6

Browse files
authored
Merge pull request #5062 from modelcontextprotocol/v2/chore/4851-python-sdk-v2
Port fetch, git and time to the MCP Python SDK v2
2 parents 0d61612 + 41a154d commit 0763da6

20 files changed

Lines changed: 936 additions & 521 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: 134 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
1+
# The fetch MCP server: one `fetch` tool and one `fetch` prompt that retrieve a
2+
# URL and convert HTML to Markdown for a model. The network policy lives here
3+
# beside the handlers because every request path (tool, prompt and robots.txt
4+
# check) must go through it: the private-address guard on each request and
5+
# redirect hop, robots.txt for autonomous fetches, and proxy normalization.
6+
#
7+
# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no
8+
# longer validates tool arguments or folds tool exceptions into `isError`
9+
# results, so `call_tool` does both with SDK v1's messages, keeping tool
10+
# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake
11+
# loop only; serving 2026-07-28 is #4853.
12+
113
import asyncio
214
import ipaddress
315
import os
416
import socket
5-
from typing import Annotated, Tuple
17+
from typing import Annotated, Any, Tuple
618
from importlib.metadata import version
719
from urllib.parse import urlparse, urlunparse
820

921
import httpx
22+
import jsonschema
1023
import markdownify
1124
import readabilipy.simple_json
12-
from mcp.shared.exceptions import McpError
13-
from mcp.server import Server
25+
from mcp.shared.exceptions import MCPError
26+
from mcp.server import Server, ServerRequestContext
27+
from mcp.server.runner import serve_loop
1428
from mcp.server.stdio import stdio_server
1529
from mcp.types import (
16-
ErrorData,
30+
CallToolRequestParams,
31+
CallToolResult,
32+
ContentBlock,
33+
GetPromptRequestParams,
1734
GetPromptResult,
35+
ListPromptsResult,
36+
ListToolsResult,
37+
PaginatedRequestParams,
1838
Prompt,
1939
PromptArgument,
2040
PromptMessage,
@@ -132,13 +152,11 @@ async def _refuse_private_destination(request: httpx.Request) -> None:
132152
return
133153
for address in addresses:
134154
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-
)
155+
raise MCPError(
156+
code=INVALID_PARAMS,
157+
message=f"Refused to fetch {request.url}: {host} resolves to {address}, "
158+
"which is not a public address. Start the server with "
159+
"--allow-private-ips to allow private, loopback and link-local addresses.",
142160
)
143161

144162

@@ -227,13 +245,11 @@ def make_client(proxy_url: str | None) -> httpx.AsyncClient:
227245
try:
228246
return httpx.AsyncClient(proxy=proxy_url)
229247
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-
)
248+
raise MCPError(
249+
code=INTERNAL_ERROR,
250+
message=f"Failed to set up the HTTP client, check the proxy "
251+
f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and "
252+
f"ALL_PROXY environment variables): {e}",
237253
)
238254

239255

@@ -245,7 +261,7 @@ async def check_may_autonomously_fetch_url(
245261
) -> None:
246262
"""
247263
Check if the URL can be fetched by the user agent according to the robots.txt file.
248-
Raises a McpError if not.
264+
Raises an MCPError if not.
249265
"""
250266
from httpx import HTTPError
251267

@@ -261,18 +277,14 @@ async def check_may_autonomously_fetch_url(
261277
headers={"User-Agent": user_agent},
262278
)
263279
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-
)
280+
raise MCPError(
281+
code=INTERNAL_ERROR,
282+
message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue",
269283
)
270284
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-
)
285+
raise MCPError(
286+
code=INTERNAL_ERROR,
287+
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",
276288
)
277289
elif 400 <= response.status_code < 500:
278290
return
@@ -282,16 +294,14 @@ async def check_may_autonomously_fetch_url(
282294
)
283295
robot_parser = Protego.parse(processed_robot_txt)
284296
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-
)
297+
raise MCPError(
298+
code=INTERNAL_ERROR,
299+
message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, "
300+
f"<useragent>{user_agent}</useragent>\n"
301+
f"<url>{url}</url>"
302+
f"<robots>\n{robot_txt}\n</robots>\n"
303+
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"
304+
f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.",
295305
)
296306

297307

@@ -318,15 +328,11 @@ async def fetch_url(
318328
timeout=30,
319329
)
320330
except HTTPError as e:
321-
raise McpError(
322-
ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}")
323-
)
331+
raise MCPError(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}")
324332
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-
)
333+
raise MCPError(
334+
code=INTERNAL_ERROR,
335+
message=f"Failed to fetch {url} - status code {response.status_code}",
330336
)
331337

332338
page_raw = response.text
@@ -378,6 +384,13 @@ class Fetch(BaseModel):
378384
]
379385

380386

387+
def tool_error(message: str) -> CallToolResult:
388+
"""A tool call that failed, as an `isError` result the model can read."""
389+
return CallToolResult(
390+
content=[TextContent(type="text", text=message)], is_error=True
391+
)
392+
393+
381394
async def serve(
382395
custom_user_agent: str | None = None,
383396
ignore_robots_txt: bool = False,
@@ -396,44 +409,46 @@ async def serve(
396409
if proxy_url:
397410
proxy_url = normalize_proxy_url(proxy_url)
398411
normalize_proxy_env()
399-
server = Server("mcp-fetch", version=SERVER_VERSION)
400412
user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS
401413
user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL
402414

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.
415+
tool = Tool(
416+
name="fetch",
417+
description="""Fetches a URL from the internet and optionally extracts its contents as markdown.
409418
410419
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-
]
420+
input_schema=Fetch.model_json_schema(),
421+
)
428422

429-
@server.call_tool()
430-
async def call_tool(name, arguments: dict) -> list[TextContent]:
423+
async def list_tools(
424+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
425+
) -> ListToolsResult:
426+
return ListToolsResult(tools=[tool])
427+
428+
async def list_prompts(
429+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
430+
) -> ListPromptsResult:
431+
return ListPromptsResult(
432+
prompts=[
433+
Prompt(
434+
name="fetch",
435+
description="Fetch a URL and extract its contents as markdown",
436+
arguments=[
437+
PromptArgument(
438+
name="url", description="URL to fetch", required=True
439+
)
440+
],
441+
)
442+
]
443+
)
444+
445+
async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]:
431446
if name != "fetch":
432447
raise ValueError(f"Unknown tool: {name}")
433448
try:
434449
args = Fetch(**arguments)
435450
except ValueError as e:
436-
raise McpError(ErrorData(code=INVALID_PARAMS, message=str(e)))
451+
raise MCPError(code=INVALID_PARAMS, message=str(e))
437452

438453
url = str(args.url)
439454

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

471-
@server.get_prompt()
472-
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
486+
async def call_tool(
487+
ctx: ServerRequestContext, params: CallToolRequestParams
488+
) -> CallToolResult:
489+
# SDK v2's low-level server neither validates arguments nor turns a
490+
# handler exception into an isError result, both of which SDK v1 did.
491+
# Both are done here, with v1's messages, so tool results are unchanged.
492+
arguments = params.arguments or {}
493+
if params.name == tool.name:
494+
try:
495+
jsonschema.validate(instance=arguments, schema=tool.input_schema)
496+
except jsonschema.ValidationError as e:
497+
return tool_error(f"Input validation error: {e.message}")
498+
try:
499+
return CallToolResult(content=await run_tool(params.name, arguments))
500+
except Exception as e:
501+
return tool_error(str(e))
502+
503+
async def get_prompt(
504+
ctx: ServerRequestContext, params: GetPromptRequestParams
505+
) -> GetPromptResult:
506+
name, arguments = params.name, params.arguments
473507
if name != "fetch":
474-
raise McpError(
475-
ErrorData(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")
476-
)
508+
raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")
477509
if not arguments or "url" not in arguments:
478-
raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required"))
510+
raise MCPError(code=INVALID_PARAMS, message="URL is required")
479511

480512
url = arguments["url"]
481513

@@ -487,7 +519,7 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
487519
allow_private_ips=allow_private_ips,
488520
)
489521
# TODO: after SDK bug is addressed, don't catch the exception
490-
except McpError as e:
522+
except MCPError as e:
491523
return GetPromptResult(
492524
description=f"Failed to fetch {url}",
493525
messages=[
@@ -506,6 +538,26 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
506538
],
507539
)
508540

541+
server = Server(
542+
"mcp-fetch",
543+
version=SERVER_VERSION,
544+
on_list_tools=list_tools,
545+
on_list_prompts=list_prompts,
546+
on_call_tool=call_tool,
547+
on_get_prompt=get_prompt,
548+
)
549+
509550
options = server.create_initialization_options()
510551
async with stdio_server() as (read_stream, write_stream):
511-
await server.run(read_stream, write_stream, options, raise_exceptions=False)
552+
# Legacy era only. Server.run() would also serve 2026-07-28 (its
553+
# dual-era loop answers server/discover and per-request envelopes);
554+
# adopting that era is #4853, so this port (#4851) serves the
555+
# handshake loop alone, the only era it served on SDK v1.
556+
async with server.lifespan(server) as lifespan_state:
557+
await serve_loop(
558+
server,
559+
read_stream,
560+
write_stream,
561+
lifespan_state=lifespan_state,
562+
init_options=options,
563+
)

‎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

0 commit comments

Comments
 (0)