Skip to content

Commit 24a6484

Browse files
authored
Merge pull request #5392 from modelcontextprotocol/v2/feat/2317-fetch-allowed-hosts
feat(fetch): add an operator host allowlist, --allowed-hosts
2 parents b6bb2f6 + 2af0580 commit 24a6484

7 files changed

Lines changed: 476 additions & 8 deletions

File tree

‎src/fetch/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,38 @@ The client receives an error naming the refused address.
191191
To fetch from local or internal hosts (a development server, an intranet page), add the argument
192192
`--allow-private-ips` to the `args` list in the configuration.
193193

194+
### Customization - Allowed hosts
195+
196+
By default the server may fetch from any host (subject to the private-address guard above). To restrict it to the
197+
hosts you name, add `--allowed-hosts` followed by one or more entries to the `args` list (the argument can also be
198+
repeated). Each entry is one of:
199+
200+
- an exact host name, such as `docs.python.org`, which matches that host only, not its subdomains;
201+
- `*.` followed by a domain, such as `*.github.com`, which matches every subdomain of it at any depth
202+
(`api.github.com`, `a.b.github.com`) but not `github.com` itself: list both to allow both;
203+
- an IP address, such as `203.0.113.7` or `2001:db8::7`, which matches a URL that uses that address literally.
204+
205+
Matching ignores case and a trailing dot, and an internationalized name may be given in either its Unicode or its
206+
`xn--` form. An entry names a host only, so it carries no scheme, port or path. The list is checked before every request
207+
the server sends, by the host name in the URL rather than by the addresses it resolves to: the requested page, its
208+
robots.txt check, and every redirect hop of either, from the tool and from the prompt alike. A request to any other host
209+
is refused before it is sent, and the client receives an error naming the refused host.
210+
211+
The allowlist and the private-address guard are separate checks, and a request must pass both. Listing an internal
212+
host, such as `localhost` or `10.0.0.5`, does not by itself let the server reach it: that also needs
213+
`--allow-private-ips`, which in turn does not lift the allowlist.
214+
215+
```json
216+
{
217+
"mcpServers": {
218+
"fetch": {
219+
"command": "uvx",
220+
"args": ["mcp-server-fetch", "--allowed-hosts", "docs.python.org", "*.wikipedia.org"]
221+
}
222+
}
223+
}
224+
```
225+
194226
### Customization - User-agent
195227

196228
By default, depending on if the request came from the model (via a tool), or was user initiated (via a prompt), the

‎src/fetch/src/mcp_server_fetch/__init__.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .allowlist import parse_allowed_host
12
from .server import serve
23

34

@@ -21,6 +22,15 @@ def main():
2122
action="store_true",
2223
help="Allow fetching private, loopback and link-local addresses (refused by default)",
2324
)
25+
parser.add_argument(
26+
"--allowed-hosts",
27+
nargs="+",
28+
action="extend",
29+
type=parse_allowed_host,
30+
metavar="HOST",
31+
help="Only fetch from these hosts: an exact host name or IP address, or "
32+
"*.example.com for any subdomain of example.com (all hosts by default)",
33+
)
2434
parser.add_argument(
2535
"--no-readability-js",
2636
action="store_true",
@@ -35,6 +45,7 @@ def main():
3545
args.ignore_robots_txt,
3646
args.proxy_url,
3747
args.allow_private_ips,
48+
args.allowed_hosts,
3849
not args.no_readability_js,
3950
)
4051
)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# The fetch server's operator-configured host allowlist (#2317). With
2+
# `--allowed-hosts`, every request the server sends (the tool's page, the
3+
# prompt's page, each robots.txt check and every redirect hop of any of them)
4+
# must name a listed host, or it is refused before it is sent. Without the flag
5+
# nothing is restricted, so existing configurations keep working.
6+
#
7+
# The allowlist names hosts, and it is checked against the host the request
8+
# connects to (`raw_host`, the ASCII form), never against resolved addresses:
9+
# a name the operator chose is trusted by name. It is a separate check from
10+
# the private-address guard in server.py, and both apply: listing `localhost`
11+
# or an intranet name does not, by itself, let the server reach a private
12+
# address; that still needs `--allow-private-ips`. Two entry forms only, to
13+
# keep the semantics obvious: an exact host (a name or an IP literal), and
14+
# `*.example.com`, which matches any subdomain at any depth of example.com but
15+
# not example.com itself. Ports and schemes are not part of an entry.
16+
17+
import argparse
18+
import ipaddress
19+
from collections.abc import Awaitable, Callable, Iterable
20+
21+
import httpx
22+
from mcp.server.mcpserver.exceptions import ToolError
23+
24+
25+
def normalize_allowed_host(entry: str) -> str:
26+
"""The canonical form of one ``--allowed-hosts`` entry, or ``ValueError``.
27+
28+
A name is lowercased, its trailing dot dropped, and IDNA-encoded to the
29+
ASCII form requests are checked in (``bücher.example`` becomes
30+
``xn--bcher-kva.example``). An IP literal is written in its compressed
31+
form, an IPv6 one with or without brackets. A wildcard is ``*.`` followed
32+
by a name.
33+
"""
34+
value = entry.strip().rstrip(".").lower()
35+
wildcard = value.startswith("*.")
36+
name = value[2:] if wildcard else value
37+
if not wildcard:
38+
try:
39+
return ipaddress.ip_address(name.strip("[]")).compressed
40+
except ValueError:
41+
pass
42+
if not name or any(c in name for c in "*/\\:@[]?#% ") or ".." in name:
43+
raise ValueError(
44+
f"{entry!r} is not a host name, an IP address or *.<domain> "
45+
"(no scheme, port, path or other wildcard)"
46+
)
47+
try:
48+
ascii_name = httpx.URL(f"http://{name}/").raw_host.decode("ascii")
49+
except httpx.InvalidURL as e:
50+
raise ValueError(f"{entry!r} is not a valid host name: {e}") from None
51+
return f"*.{ascii_name}" if wildcard else ascii_name
52+
53+
54+
def parse_allowed_host(entry: str) -> str:
55+
"""``normalize_allowed_host`` as an argparse ``type``, with its message."""
56+
try:
57+
return normalize_allowed_host(entry)
58+
except ValueError as e:
59+
raise argparse.ArgumentTypeError(str(e)) from None
60+
61+
62+
def normalize_allowed_hosts(entries: Iterable[str] | None) -> tuple[str, ...]:
63+
"""Every entry normalized; an empty tuple means no allowlist."""
64+
return tuple(normalize_allowed_host(entry) for entry in entries or ())
65+
66+
67+
def host_is_allowed(host: str, allowed_hosts: tuple[str, ...]) -> bool:
68+
"""Whether ``host`` (a request's ``raw_host``) matches a normalized entry."""
69+
host = host.rstrip(".").lower()
70+
try:
71+
host = ipaddress.ip_address(host).compressed
72+
except ValueError:
73+
pass
74+
for entry in allowed_hosts:
75+
if entry.startswith("*."):
76+
if host.endswith(entry[1:]):
77+
return True
78+
elif host == entry:
79+
return True
80+
return False
81+
82+
83+
def refuse_unlisted_destination(
84+
allowed_hosts: tuple[str, ...],
85+
) -> Callable[[httpx.Request], Awaitable[None]]:
86+
"""An httpx request hook refusing any request to a host not on the list.
87+
88+
httpx runs request hooks before every request it sends, redirect hops
89+
included, so a listed host cannot redirect the fetch to an unlisted one.
90+
The error does not repeat the list, which is the operator's configuration.
91+
"""
92+
93+
async def hook(request: httpx.Request) -> None:
94+
host = request.url.raw_host.decode("ascii")
95+
if not host_is_allowed(host, allowed_hosts):
96+
raise ToolError(
97+
f"Refused to fetch {request.url}: {host} is not one of the "
98+
"hosts this server is allowed to fetch from (--allowed-hosts)."
99+
)
100+
101+
return hook

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import ipaddress
2424
import os
2525
import socket
26-
from collections.abc import Awaitable, Callable, Mapping
26+
from collections.abc import Awaitable, Callable, Mapping, Sequence
2727
from typing import Annotated, Any, Tuple
2828
from importlib.metadata import version
2929
from urllib.parse import urlparse, urlunparse
@@ -33,6 +33,7 @@
3333
import readabilipy.simple_json
3434
from .readability_js import extract_article
3535
from bs4 import BeautifulSoup, Tag
36+
from .allowlist import normalize_allowed_hosts, refuse_unlisted_destination
3637
from mcp.server import MCPServer, ServerRequestContext
3738
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware
3839
from 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

Comments
 (0)