2323import ipaddress
2424import os
2525import socket
26- from collections .abc import Awaitable , Callable , Mapping
26+ from collections .abc import Awaitable , Callable , Mapping , Sequence
2727from typing import Annotated , Any , Tuple
2828from importlib .metadata import version
2929from urllib .parse import urlparse , urlunparse
3333import readabilipy .simple_json
3434from .readability_js import extract_article
3535from bs4 import BeautifulSoup , Tag
36+ from .allowlist import normalize_allowed_hosts , refuse_unlisted_destination
3637from mcp .server import MCPServer , ServerRequestContext
3738from mcp .server .context import CallNext , HandlerResult , ServerMiddleware
3839from mcp .server .mcpserver .exceptions import ToolError
@@ -479,6 +480,7 @@ async def check_may_autonomously_fetch_url(
479480 user_agent : str ,
480481 proxy_url : str | None = None ,
481482 allow_private_ips : bool = False ,
483+ allowed_hosts : tuple [str , ...] = (),
482484) -> None :
483485 """
484486 Check if the URL can be fetched by the user agent according to the robots.txt file.
@@ -489,8 +491,12 @@ async def check_may_autonomously_fetch_url(
489491 robot_txt_url = get_robots_txt_url (url )
490492
491493 async with make_client (proxy_url ) as client :
494+ hooks : list [Callable [[httpx .Request ], Awaitable [None ]]] = []
495+ if allowed_hosts :
496+ hooks .append (refuse_unlisted_destination (allowed_hosts ))
492497 if not allow_private_ips :
493- client .event_hooks = {"request" : [_refuse_private_destination ]}
498+ hooks .append (_refuse_private_destination )
499+ client .event_hooks = {"request" : hooks }
494500 try :
495501 response = await client .get (
496502 robot_txt_url ,
@@ -543,6 +549,7 @@ async def fetch_url(
543549 force_raw : bool = False ,
544550 proxy_url : str | None = None ,
545551 allow_private_ips : bool = False ,
552+ allowed_hosts : tuple [str , ...] = (),
546553 check_robots_txt : bool = False ,
547554 use_readability_js : bool = True ,
548555) -> Tuple [str , str ]:
@@ -564,11 +571,13 @@ async def _check_redirect_robots_txt(request: httpx.Request) -> None:
564571 seen_first_request = True
565572 return
566573 await check_may_autonomously_fetch_url (
567- str (request .url ), user_agent , proxy_url , allow_private_ips
574+ str (request .url ), user_agent , proxy_url , allow_private_ips , allowed_hosts
568575 )
569576
570577 async with make_client (proxy_url ) as client :
571578 hooks : list [Callable [[httpx .Request ], Awaitable [None ]]] = []
579+ if allowed_hosts :
580+ hooks .append (refuse_unlisted_destination (allowed_hosts ))
572581 if not allow_private_ips :
573582 hooks .append (_refuse_private_destination )
574583 if check_robots_txt :
@@ -660,6 +669,7 @@ def create_server(
660669 ignore_robots_txt : bool = False ,
661670 proxy_url : str | None = None ,
662671 allow_private_ips : bool = False ,
672+ allowed_hosts : Sequence [str ] | None = None ,
663673 use_readability_js : bool = True ,
664674) -> MCPServer :
665675 """Build the fetch server: the `fetch` tool, the `fetch` prompt, and the guard.
@@ -669,6 +679,7 @@ def create_server(
669679 if proxy_url :
670680 proxy_url = normalize_proxy_url (proxy_url )
671681 normalize_proxy_env ()
682+ allowed = normalize_allowed_hosts (allowed_hosts )
672683 user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS
673684 user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL
674685
@@ -729,7 +740,7 @@ async def fetch(
729740
730741 if not ignore_robots_txt :
731742 await check_may_autonomously_fetch_url (
732- page_url , user_agent_autonomous , proxy_url , allow_private_ips
743+ page_url , user_agent_autonomous , proxy_url , allow_private_ips , allowed
733744 )
734745
735746 content , prefix = await fetch_url (
@@ -738,6 +749,7 @@ async def fetch(
738749 force_raw = raw ,
739750 proxy_url = proxy_url ,
740751 allow_private_ips = allow_private_ips ,
752+ allowed_hosts = allowed ,
741753 check_robots_txt = not ignore_robots_txt ,
742754 use_readability_js = use_readability_js ,
743755 )
@@ -771,6 +783,7 @@ async def fetch_prompt(
771783 user_agent_manual ,
772784 proxy_url = proxy_url ,
773785 allow_private_ips = allow_private_ips ,
786+ allowed_hosts = allowed ,
774787 use_readability_js = use_readability_js ,
775788 )
776789 except ToolError as e :
@@ -785,6 +798,7 @@ async def serve(
785798 ignore_robots_txt : bool = False ,
786799 proxy_url : str | None = None ,
787800 allow_private_ips : bool = False ,
801+ allowed_hosts : Sequence [str ] | None = None ,
788802 use_readability_js : bool = True ,
789803) -> None :
790804 """Run the fetch MCP server over stdio.
@@ -793,6 +807,10 @@ async def serve(
793807 custom_user_agent: Optional custom User-Agent string to use for requests
794808 ignore_robots_txt: Whether to ignore robots.txt restrictions
795809 proxy_url: Optional proxy URL to use for requests
810+ allowed_hosts: When given, the only hosts the server may fetch from:
811+ exact hosts, or ``*.example.com`` for any subdomain of it, checked
812+ in addition to the private-address guard. Empty or None allows
813+ every host
796814 allow_private_ips: Allow fetching private, loopback and link-local
797815 addresses, which are refused by default
798816 use_readability_js: Extract HTML with Readability.js under Node.js
@@ -804,6 +822,7 @@ async def serve(
804822 ignore_robots_txt ,
805823 proxy_url ,
806824 allow_private_ips ,
825+ allowed_hosts ,
807826 use_readability_js ,
808827 )
809828 # Both eras (#4853): the SDK's dual-era loop lets the client's first
0 commit comments