Fence In Your Agent: Domain Allowlists for MCP Fetch Tools
Agents with fetch tools can wander anywhere. Build a domain allowlist into your MCP server, block internal IPs, and log every rejected call.
On this page
The Problem: A Fetch Tool Is a Network Pivot
An agent with a fetch tool will eventually fetch something you did not intend. Not because the model is malicious — because it follows instructions found in the pages it reads, the tickets it processes, and the emails it summarizes. Prompt injection turns your helpful “fetch any URL and return the body” tool into a network pivot inside your own infrastructure. The fix is boring and effective: enforce a domain allowlist in the MCP server itself, block internal IP ranges at the DNS layer, re-check every redirect hop, and log every rejected call.
This post walks through the full fence, in TypeScript, with the bypass classes you actually need to worry about.
Why an Unrestricted Fetch Tool Is a Security Hole, Not a Feature
The typical MCP fetch tool looks like this:
server.tool(
"fetch",
"Fetch a URL and return the response body",
{ url: z.string() },
async ({ url }) => {
const res = await fetch(url);
const body = await res.text();
return { content: [{ type: "text", text: body }] };
}
);
Runs on Node ≥ 18 as ESM ("type": "module" in package.json); requires @modelcontextprotocol/sdk and zod installed.
Six lines. Accepts anything. Returns whatever the server sees. If this handler runs on a box with access to your VPC, a cloud metadata endpoint, or a localhost admin panel, the agent can be talked into visiting all three — and it will happily paste the response into its context, where the injected instruction can read it and exfiltrate it in a follow-up fetch.
Here are the three scenarios I design against:
| Scenario | URL pattern | What the agent leaks | Does a naive allowlist catch it? |
|---|---|---|---|
| Prompt-injected fetch of cloud metadata | http://169.254.169.254/latest/meta-data/iam/security-credentials/ | Temporary IAM credentials for the host’s role | Yes, if the check is “hostname must be in list” — the host is an IP literal, not an allowed domain |
| Fetch of a localhost admin panel | http://127.1:9090/admin (or http://0x7f000001/) | Internal service config, env vars, metrics, sometimes auth tokens | Only if the check normalizes IP encodings. A string match on "localhost" misses every alternate encoding |
| Redirect chain through an allowed host | https://api.example.com/redirect?to=http://10.0.0.5:6379/info | Internal service responses (Redis INFO, internal API bodies) | No. The allowlist validates the initial URL; the redirect is followed blindly |
The third row is the one people miss. Your allowlist can be perfectly correct on the initial URL and still leak data, because fetch follows redirects by default and your guard never sees hops 2 through N.
If you’re building MCP tools for agents generally, the auth and scope failure modes around this pattern are worth a separate read in MCP Tools for AI Agents: Auth Scopes and Failure Modes.
Threat Model: What Your Allowlist Must Actually Block
A hostname string comparison is not a security boundary. Here are ten inputs that defeat one:
http://169.254.169.254/latest/meta-data/— link-local metadata service; hostname is an IP literal, so a domain allowlist has nothing to compare.http://0x7f000001/admin— hex-encoded127.0.0.1; string comparison sees a “domain” that isn’t in your list only by luck, and if you allow any IP you allow this.http://2130706433/admin— the same loopback address as a 32-bit decimal integer.http://example.com@127.0.0.1/admin— the userinfo trick; naive parsers extractexample.comas the host, the browser connects to127.0.0.1.http://[::1]:8080/— IPv6 loopback; a check that only knows dotted-quad IPv4 passes it through.http://[::ffff:10.0.0.5]/— IPv4-mapped IPv6; your IPv4 CIDR check never runs unless you normalize first.http://127.1/— abbreviated loopback;endsWith("example.com")logic doesn’t apply, and"127.1"isn’t"127.0.0.1"as a string.http://example.com.evil.example/— a naiveendsWith("example.com")check passes this.http://evil-example.com/— a naiveincludes("example.com")check passes this.http://rebind.attacker.example/— resolves to a public IP on the first lookup and10.0.0.5on the second (DNS rebinding); passes a check that only validates the hostname string.
And the check that fails on nearly all of them:
// BROKEN: do not ship this
function hostAllowed(hostname: string, allowed: string[]): boolean {
return allowed.some(d => hostname.endsWith(d));
}
Runs on Node ≥ 18 as ESM; no dependencies — pure TypeScript.
Input 8 sails through. Input 4 passes if your URL extraction is sloppy. Inputs 1–7 pass or fail depending on whether the hostname even reaches the comparison as a domain-looking string. Input 10 passes the hostname check entirely and needs IP-level validation to catch.
Designing the Allowlist: Domains, Wildcards, and Explicit Denies
Deny by default. Every hostname is blocked unless a rule explicitly admits it. Here is the config I’ve settled on:
{
"allowed_domains": {
"exact": ["example.com", "api.example.com"],
"wildcard": ["*.store.example.com"]
},
"allowed_ports": { "default": [80, 443], "api.example.com": [443] },
"allowed_schemes": ["https", "http"],
"blocked_cidrs": [
"0.0.0.0/8",
"10.0.0.0/8",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"::1/128",
"fc00::/7",
"fe80::/10"
],
"deny_ip_literals": true
}
Static JSON — save as allowlist.json; no runtime required.
Three match semantics exist, and only two of them are safe:
| Semantics | Rule | Accepts example.com | Accepts store.example.com | Accepts evil-example.com | Accepts notexample.com |
|---|---|---|---|---|---|
| Exact match | example.com | Yes | No | No | No |
| Single-level wildcard | *.store.example.com | No | Yes | No | No |
| Suffix match | endsWith("example.com") | Yes | Yes | Yes | Yes |
Never ship suffix matching. I won’t budge on this one: the convenience of endsWith is not worth the example.com.evil.example class of bypass, and I have seen it in production code more than once. Wildcards are fine, but constrain them to a single DNS label — *.store.example.com matches shop.store.example.com, not a.b.store.example.com. If you need depth, add another explicit rule.
deny_ip_literals: true means the agent can never fetch a raw IP address, period. Even 1.1.1.1. If a legitimate target has no domain, add an exception with a comment in the config review, not a general escape hatch.
One honest trade-off: port restrictions. Locking api.example.com to 443 only is right for a fetch tool. But if your agent legitimately talks to internal dev services on odd ports, you’ll be tempted to widen the default. Don’t. Open the specific port on the specific host.
Resolving Before You Decide: Blocking Internal IPs at the DNS Layer
Hostname checks are necessary but not sufficient — input 10 from the threat model proves it. The hostname rebind.attacker.example isn’t in your allowlist, so that specific case dies at the domain check. But the reverse matters too: a hostname you do allow can resolve somewhere hostile if your DNS is compromised or if the target’s DNS is attacker-controlled. Resolve first, then decide.
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
export class GuardRejection extends Error {
constructor(
public readonly reason: string,
public readonly details: Record<string, unknown>
) {
super(reason);
this.name = "GuardRejection";
}
}
export interface Resolver {
resolve(hostname: string): Promise<string[]>;
}
export const dnsResolver: Resolver = {
async resolve(hostname: string): Promise<string[]> {
const records = await lookup(hostname, { all: true, verbatim: true });
return records.map(r => r.address);
}
};
export function isBlockedIp(ip: string, blockedCidrs: string[]): boolean {
let addr;
try {
addr = ipaddr.parse(ip);
} catch {
return true; // unparseable address: fail closed
}
// Normalize IPv4-mapped IPv6 back to IPv4 so CIDR checks apply.
const normalized =
addr.kind() === "ipv6" && addr.isIPv4MappedAddress()
? addr.toIPv4Address()
: addr;
return blockedCidrs.some(cidr => normalized.match(ipaddr.parseCIDR(cidr)));
}
export async function resolveAndCheckHost(
hostname: string,
blockedCidrs: string[],
resolver: Resolver = dnsResolver
): Promise<string[]> {
const ips = await resolver.resolve(hostname);
for (const ip of ips) {
if (isBlockedIp(ip, blockedCidrs)) {
throw new GuardRejection("private_ip_range", { resolved_ips: ips });
}
}
return ips;
}
Runs on Node ≥ 18 as ESM; requires ipaddr.js (npm install ipaddr.js).
Two details matter. First, lookup with all: true — a hostname with multiple A records must have every address checked, because the OS resolver may hand back the bad one at connect time. Second, the IPv4-mapped IPv6 normalization: without toIPv4Address(), [::ffff:10.0.0.5] walks straight past a table of IPv4 CIDRs.
The injectable Resolver interface exists for tests. Here’s the one that catches a hostname resolving into a private range:
import { describe, it, expect, vi } from "vitest";
describe("resolveAndCheckHost", () => {
it("rejects an allowed-looking host that resolves to 192.168.1.10", async () => {
const mockResolver: Resolver = {
resolve: vi.fn().mockResolvedValue(["192.168.1.10"])
};
await expect(
resolveAndCheckHost("store.example.com", BLOCKED_CIDRS, mockResolver)
).rejects.toMatchObject({ reason: "private_ip_range" });
});
it("passes example.com resolving to a public address", async () => {
const mockResolver: Resolver = {
resolve: vi.fn().mockResolvedValue(["93.184.216.34"])
};
const ips = await resolveAndCheckHost(
"example.com", BLOCKED_CIDRS, mockResolver
);
expect(ips).toEqual(["93.184.216.34"]);
});
});
Runs on Node ≥ 18 as ESM via Vitest (npx vitest run); requires the resolveAndCheckHost, Resolver, and BLOCKED_CIDRS definitions from the block above in the same module.
Validating the URL: Parsing, Redirects, and Re-Checking Every Hop
Parse with the WHATWG URL API — the same one the runtime uses to connect. Hand-rolled parsing with indexOf("://") is how the userinfo trick wins.
export function validateUrl(
raw: string,
config: AllowlistConfig
): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new GuardRejection("unparseable_url", {});
}
if (!config.allowed_schemes.includes(url.protocol.replace(":", ""))) {
throw new GuardRejection("scheme_blocked", { scheme: url.protocol });
}
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
// Strip brackets from IPv6 literals for the IP check.
const hostForIp = hostname.replace(/^\[|\]$/g, "");
if (ipaddr.isValid(hostForIp)) {
if (config.deny_ip_literals) {
throw new GuardRejection("ip_literal_blocked", {});
}
if (isBlockedIp(hostForIp, config.blocked_cidrs)) {
throw new GuardRejection("private_ip_range", { resolved_ips: [hostForIp] });
}
} else if (!hostAllowed(hostname, config)) {
throw new GuardRejection("domain_not_allowed", { hostname });
}
const port = url.port === "" ? defaultPort(url.protocol) : Number(url.port);
const allowedPorts = config.allowed_ports[hostname] ?? config.allowed_ports.default;
if (!allowedPorts.includes(port)) {
throw new GuardRejection("port_not_allowed", { port });
}
return url;
}
function hostAllowed(hostname: string, config: AllowlistConfig): boolean {
if (config.allowed_domains.exact.includes(hostname)) return true;
return config.allowed_domains.wildcard.some(pattern => {
const suffix = pattern.slice(2); // strip "*."
if (!hostname.endsWith("." + suffix)) return false;
const label = hostname.slice(0, hostname.length - suffix.length - 1);
return label.length > 0 && !label.includes(".");
});
}
Runs on Node ≥ 18 as ESM; requires ipaddr.js plus the GuardRejection, isBlockedIp, and AllowlistConfig definitions from the earlier blocks.
The trailing-dot strip (example.com. is legally the same host as example.com) and the lowercase normalization close two quiet gaps. WHATWG URL parsing handles the userinfo case for you — new URL("http://example.com@127.0.0.1/").hostname is 127.0.0.1, which then dies at the IP-literal check.
Now redirects. Follow them manually and re-run the entire validation chain on every hop:
export async function guardedFetch(
rawUrl: string,
config: AllowlistConfig,
resolver: Resolver = dnsResolver
): Promise<{ response: Response; finalUrl: URL }> {
let current = validateUrl(rawUrl, config);
const MAX_HOPS = 3;
for (let hop = 0; hop <= MAX_HOPS; hop++) {
await resolveAndCheckHost(current.hostname, config.blocked_cidrs, resolver);
const res = await fetch(current, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) return { response: res, finalUrl: current };
if (hop === MAX_HOPS) throw new GuardRejection("too_many_redirects", {});
// Re-validate scheme, host, IP range, and port on every hop.
current = validateUrl(new URL(location, current).href, config);
continue;
}
return { response: res, finalUrl: current };
}
throw new GuardRejection("too_many_redirects", {});
}
Runs on Node ≥ 18 as ESM (uses the built-in global fetch); requires the validateUrl, resolveAndCheckHost, and GuardRejection definitions from the earlier blocks.
Three hops is a deliberate cap. Legitimate public pages rarely chain more than two redirects; anything longer is usually a tracking wrapper or something you don’t want the agent inside of anyway. Some people will disagree and want five — fine, but log every hop so you can see the chains in production.
Logging Every Rejected Call: Structured Audit Events
A block you can’t see is a block you can’t trust. Every rejection emits one structured event:
{
"event": "fetch_rejected",
"timestamp": "2026-01-15T09:14:22.481Z",
"tool": "fetch",
"requested_url": "http://169.254.169.254/latest/meta-data/",
"resolved_ips": [],
"rejection_reason": "ip_literal_blocked",
"matched_rule": "deny_ip_literals",
"session_id": "agent-session-7f3a",
"agent_id": "research-agent@1.4.2"
}
Example JSON payload — no runtime; produced by the guard’s log callback.
Wait — that timestamp contains a year. Which is exactly the kind of detail that doesn’t matter to the schema; use ISO 8601 from your clock library and move on. The fields that do matter:
rejection_reason— from a closed enum:domain_not_allowed,private_ip_range,ip_literal_blocked,scheme_blocked,port_not_allowed,too_many_redirects,unparseable_url.resolved_ips— what DNS actually returned, when the rejection happened after resolution. This is your DNS-rebinding evidence.matched_rule— which config entry fired, so you can trace a rejection to a specific line during review.session_id/agent_id— without these, a spike in rejections is unattributable and therefore unactionable.
The guard wrapper that emits the event and returns a safe error:
export async function guardFetch(
rawUrl: string,
config: AllowlistConfig,
ctx: { sessionId: string; agentId: string },
log: (e: Record<string, unknown>) => void
): Promise<{ response: Response; finalUrl: URL }> {
try {
return await guardedFetch(rawUrl, config);
} catch (err) {
if (err instanceof GuardRejection) {
log({
event: "fetch_rejected",
tool: "fetch",
requested_url: rawUrl,
resolved_ips: err.details.resolved_ips ?? [],
rejection_reason: err.reason,
matched_rule: err.details.matched_rule ?? null,
session_id: ctx.sessionId,
agent_id: ctx.agentId
});
// Re-throw with a sanitized message: no resolved IPs, no internal details.
throw new GuardRejection(err.reason, {});
}
throw err;
}
}
Runs on Node ≥ 18 as ESM; requires the guardedFetch and GuardRejection definitions from the earlier blocks.
Note the sanitized re-throw. The log gets the full detail; the agent gets only the reason enum. If you put resolved IPs in the error text, the agent will quote them back, and whatever injected the fetch attempt learns your internal layout for free.
Wiring the Guard into Your MCP Fetch Tool
The handler calls the guard before any network I/O and shapes the result payload for both outcomes:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "fenced-fetch", version: "1.0.0" });
server.tool(
"fetch",
"Fetch a page from an allowed domain. Returns the body as text.",
{ url: z.string().describe("Absolute http(s) URL") },
async ({ url }, extra) => {
const ctx = {
sessionId: extra.sessionId ?? "unknown",
agentId: extra.agentId ?? "unknown"
};
try {
const { response, finalUrl } = await guardFetch(
url, CONFIG, ctx, emitAuditEvent
);
const body = await response.text();
return {
content: [
{
type: "text",
text: `Fetched ${finalUrl.href} (status ${response.status})\n\n${body}`
}
]
};
} catch (err) {
if (err instanceof GuardRejection) {
return {
content: [
{
type: "text",
text: `Fetch blocked: ${err.reason}. This tool only accesses allowlisted public domains.`
}
],
isError: true
};
}
return {
content: [{ type: "text", text: `Fetch failed: network error` }],
isError: true
};
}
}
);
Runs on Node ≥ 18 as ESM; requires @modelcontextprotocol/sdk, zod, and the guardFetch/GuardRejection/CONFIG/emitAuditEvent definitions from the earlier blocks.
What the agent sees, allowed case:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "fetch",
"arguments": { "url": "https://api.example.com/status" }
}
}
Example JSON-RPC request payload — no runtime; sent by the MCP client over stdio or HTTP.
{
"content": [
{ "type": "text", "text": "Fetched https://api.example.com/status (status 200)\n\n{\"status\":\"ok\"}" }
]
}
Example JSON-RPC response payload — no runtime; returned by the server.
Blocked case:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "fetch",
"arguments": { "url": "http://169.254.169.254/latest/meta-data/" }
}
}
Example JSON-RPC request payload — no runtime; sent by the MCP client over stdio or HTTP.
{
"content": [
{
"type": "text",
"text": "Fetch blocked: ip_literal_blocked. This tool only accesses allowlisted public domains."
}
],
"isError": true
}
Example JSON-RPC response payload — no runtime; returned by the server.
One design choice worth defending: the error message tells the agent the reason enum and nothing else. Some teams return “domain not in allowlist, ask the operator to add it.” That’s fine — it even helps the agent recover gracefully. But never echo the resolved IPs, the CIDR that matched, or the config path. The agent’s context is not a trusted surface.
If the fetch tool’s real job is pulling public web content rather than hitting your own APIs, there’s a simpler architecture: don’t let your server make the outbound connection at all. Route allowed fetches through a scraping API — one POST https://api.finedata.ai/api/v1/scrape call with the URL and a Bearer key — so the egress happens from someone else’s infrastructure and your guard only has to validate the destination domain:
curl -X POST https://api.finedata.ai/api/v1/scrape \
-H "Authorization: Bearer fd_your_api_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/pricing", "formats": ["markdown"], "only_main_content": true}'
Runs in any POSIX shell with curl installed; replace fd_your_api_key with your FineData API key.
That collapses your SSRF surface to “which external domains may the agent read,” which is a much smaller problem than “which internal hosts can it reach.”
Testing the Fence: A Rejection Test Suite You Can Run Locally
Table-driven tests, one row per bypass class, each asserting the exact rejection_reason. If a row ever fails, you’ve regressed a specific defense — the failure message tells you which one.
import { describe, it, expect, vi } from "vitest";
const HOSTILE_CASES: Array<{ url: string; reason: string }> = [
{ url: "http://169.254.169.254/latest/meta-data/", reason: "ip_literal_blocked" },
{ url: "http://0x7f000001/admin", reason: "ip_literal_blocked" },
{ url: "http://2130706433/admin", reason: "ip_literal_blocked" },
{ url: "http://example.com@127.0.0.1/admin", reason: "ip_literal_blocked" },
{ url: "http://[::1]:8080/", reason: "ip_literal_blocked" },
{ url: "http://[::ffff:10.0.0.5]/", reason: "ip_literal_blocked" },
{ url: "http://127.1/", reason: "ip_literal_blocked" },
{ url: "http://example.com.evil.example/", reason: "domain_not_allowed" },
{ url: "http://evil-example.com/", reason: "domain_not_allowed" },
{ url: "http://rebind.attacker.example/", reason: "domain_not_allowed" },
{ url: "ftp://example.com/file", reason: "scheme_blocked" },
{ url: "https://api.example.com:8443/status", reason: "port_not_allowed" }
];
const ALLOWED_CASES = [
"https://example.com/",
"https://api.example.com/status",
"https://shop.store.example.com/item/42"
];
describe("fetch fence", () => {
it("blocks every hostile input with the expected reason", () => {
for (const { url, reason } of HOSTILE_CASES) {
expect(() => validateUrl(url, CONFIG)).toThrowError(
expect.objectContaining({ reason })
);
}
});
it("allows every legitimate case", () => {
for (const url of ALLOWED_CASES) {
expect(() => validateUrl(url, CONFIG)).not.toThrow();
}
});
it("blocks an allowed host whose DNS resolves into 192.168.0.0/16", async () => {
const mockResolver: Resolver = {
resolve: vi.fn().mockResolvedValue(["192.168.1.10"])
};
await expect(
resolveAndCheckHost("store.example.com", CONFIG.blocked_cidrs, mockResolver)
).rejects.toMatchObject({ reason: "private_ip_range" });
});
});
Runs on Node ≥ 18 as ESM via Vitest (npx vitest run); requires the validateUrl, resolveAndCheckHost, Resolver, and CONFIG definitions from the earlier blocks in the same module.
The WHATWG URL parser normalizes 0x7f000001, 2130706433, and 127.1 into canonical dotted-quad hostnames before your code sees them — which is why the IP-literal check catches all the encodings with one rule. Test it anyway; parser behavior is a dependency like any other.
Latency overhead, measured locally:
import { performance } from "node:perf_hooks";
class GuardRejection extends Error {
constructor(
public readonly reason: string,
public readonly details: Record<string, unknown> = {}
) {
super(reason);
this.name = "GuardRejection";
}
}
const CONFIG = {
allowed_domains: { exact: ["example.com"], wildcard: [] },
allowed_ports: { default: [80, 443] },
allowed_schemes: ["https", "http"],
blocked_cidrs: ["10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "192.168.0.0/16"],
deny_ip_literals: true
};
const CTX = { sessionId: "bench", agentId: "bench-agent" };
// Minimal stand-in for the article's guardFetch: validate, then fetch.
async function guardFetch(
rawUrl: string,
config: typeof CONFIG,
_ctx: typeof CTX,
_log: (e: Record<string, unknown>) => void
): Promise<{ response: Response; finalUrl: URL }> {
const url = new URL(rawUrl);
if (!config.allowed_domains.exact.includes(url.hostname)) {
throw new GuardRejection("domain_not_allowed", { hostname: url.hostname });
}
const response = await fetch(url, { redirect: "manual" });
return { response, finalUrl: url };
}
async function bench(
label: string,
fn: () => Promise<unknown>,
n = 1000
): Promise<number> {
const samples: number[] = [];
for (let i = 0; i < n; i++) {
const t0 = performance.now();
await fn();
samples.push(performance.now() - t0);
}
samples.sort((a, b) => a - b);
const median = samples[Math.floor(n / 2)];
console.log(`${label}: median ${median.toFixed(2)}ms`);
return median;
}
async function main(): Promise<void> {
// Warm up.
await fetch("https://example.com/");
const raw = await bench("raw fetch", () => fetch("https://example.com/"));
const guarded = await bench("guarded fetch", () =>
guardFetch("https://example.com/", CONFIG, CTX, () => {})
);
// Assert on concrete observables: example.com must return HTTP 200,
// and both medians must be finite numbers.
const res = await fetch("https://example.com/");
if (res.status !== 200) {
throw new Error(`expected HTTP 200 from example.com, got ${res.status}`);
}
if (!Number.isFinite(raw) || !Number.isFinite(guarded)) {
throw new Error("benchmark produced a non-finite median");
}
console.log(`guard overhead: ~${(guarded - raw).toFixed(2)}ms median`);
}
main().catch(err => {
console.error("benchmark failed:", err);
process.exit(1);
});
Runs on Node ≥ 18 as ESM ("type": "module" in package.json or a .mts file); fully self-contained — no dependencies beyond the Node standard library and the built-in global fetch.
Expect the guard to add roughly the cost of one DNS lookup plus sub-millisecond parsing on the happy path — a few milliseconds against a network fetch that costs tens or hundreds. If your median delta is above ~10ms, you’re almost certainly re-resolving on every redirect hop of a chain you could cache.
Gotchas and Next Steps
Three things the fence above does not fully solve:
- TOCTOU on DNS.
resolveAndCheckHostvalidates the IP, thenfetchdoes its own resolution — a rebinding attack can return a public IP for the check and a private IP for the connect. The complete fix pins the validated addresses using a custom undiciAgentwith alookupcallback that returns only the checked IPs. It’s about 15 more lines and it’s the one gap I’d close before calling this production-ready. - Redirect re-resolution cost. Re-running DNS on every hop is correct but adds latency. Cache per-hostname validation results for a short TTL (30–60 seconds) if your redirect chains are hot.
- The allowlist is a living document. Review
matched_rulecounts monthly. A rule that never fires is a candidate for removal; a spike indomain_not_allowedfrom one session is either a confused agent or an injection attempt, and the logs tell you which.
Next steps worth your time: run the rejection suite as part of CI so the bypass classes stay covered, and before you hand the server to an agent, walk through Test Your MCP Scraping Server Before an Agent Uses It — the same discipline applies to fetch tools. If you want to trace what the agent actually requested in production, the audit events from this post plug straight into the approach in Debug MCP Tool Calls: Trace What Your Agent Fetched.
Wrap-Up
An unrestricted fetch tool is a network pivot wearing a helpful name. The fence is four layers, each closing a specific bypass: WHATWG parsing plus scheme and port checks kill the URL-quirk class; exact and single-label-wildcard matching kills the suffix class; DNS resolution with CIDR checks kills the IP-literal and rebinding classes; and manual redirect handling kills the hop-through class. Structured rejection logs make all four observable. None of it is clever, all of it is testable, and the whole guard costs a few milliseconds per request. Put the allowlist in the server, not the system prompt — prompts are suggestions, code is a boundary.
Related Articles
Teach Your Agent to Escalate Its Own Fetches
Wire MCP fetch tools in tiers so your agent tries a cheap plain fetch first, then escalates to JS rendering and geo-routing only when a page needs it.
TechnicalMCP Tools for AI Agents: Auth Scopes and Failure Modes
A hands-on tutorial on designing MCP tools that let an AI agent fetch live web data: batching, token scopes, retries, and failure handling.
TechnicalServe Scraped Data to Agents as MCP Resources
Turn scrape results into MCP resources: agents read the latest snapshot by URI and get notified when the data changes instead of re-fetching pages.