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+
113import asyncio
214import ipaddress
315import os
416import socket
5- from typing import Annotated , Tuple
17+ from typing import Annotated , Any , Tuple
618from importlib .metadata import version
719from urllib .parse import urlparse , urlunparse
820
921import httpx
22+ import jsonschema
1023import markdownify
1124import 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
1428from mcp .server .stdio import stdio_server
1529from 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+
381394async 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
410419Although 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+ )
0 commit comments