<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Mihai Dinculescu</title>
    <subtitle>Notes on software, home labs and whatever else I&#39;m tinkering with.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://mihai.dinculescu.dev/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://mihai.dinculescu.dev"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-09T00:00:00+00:00</updated>
    <id>https://mihai.dinculescu.dev/atom.xml</id>
    <entry xml:lang="en">
        <title>Give your AI agent a better web fetch capability for free*</title>
        <published>2026-09-09T00:00:00+00:00</published>
        <updated>2026-09-09T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://mihai.dinculescu.dev/posts/better-web-fetch-for-your-ai-agent/"/>
        <id>https://mihai.dinculescu.dev/posts/better-web-fetch-for-your-ai-agent/</id>
        
        <content type="html" xml:base="https://mihai.dinculescu.dev/posts/better-web-fetch-for-your-ai-agent/">&lt;h2 id=&quot;why-this-suddenly-became-important-to-me&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-this-suddenly-became-important-to-me&quot; aria-label=&quot;Anchor link for: why-this-suddenly-became-important-to-me&quot;&gt;Why this suddenly became important to me&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I was chilling in bed when my Garmin watch buzzed. New firmware. I don&#39;t know
about you, but I always get excited about new software versions, even though I
know deep down that the odds are it&#39;s a boring set of changes that won&#39;t make
my day any better. Still, an engineer like me put work into shipping it. I
want to know what it brings.&lt;/p&gt;
&lt;p&gt;So I open the Claude app on my phone and ask Phoebe (the name Claude goes by
for me) what&#39;s new in that version. The reply was unhelpful. Phoebe couldn&#39;t
fetch the Garmin forum page that holds the release notes, because the forum&#39;s
&lt;code&gt;robots.txt&lt;/code&gt; forbids it. I tried explaining that she&#39;s my personal assistant,
not a crawler, and got nowhere: Claude can&#39;t change that setting. Along the
way she admitted a second incriminating fact: she can&#39;t run JavaScript either.&lt;/p&gt;
&lt;h2 id=&quot;what&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what&quot; aria-label=&quot;Anchor link for: what&quot;&gt;What&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Web fetch is the second most important tool an agent has, right after web
search. And the default one that ships with Claude on the web and on the
desktop has two big downsides.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It&#39;s held to ransom by &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/strong&gt; That mechanism was invented to
instruct crawlers that sweep an entire site. I&#39;d argue it&#39;s the wrong
instrument for an agent fetching one specific page at the express request of
a human. The page is public; the human could open it in a tab. The agent is
just a slower way of reading it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It can&#39;t run JavaScript.&lt;/strong&gt; A plain fetch is a single HTTP GET. Anything
rendered client-side, which is a lot of the web, comes back as an empty
shell.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The solution is surprisingly simple: use a tool that browses the web the way a
human does. And what better candidate than the one we already use to test
modern web apps end to end, by imitating real users as closely as possible?&lt;/p&gt;
&lt;p&gt;Enter &lt;a rel=&quot;external&quot; href=&quot;https://github.com/microsoft/playwright-mcp&quot;&gt;Playwright MCP&lt;/a&gt;. It ticks
every box:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;it drives a real browser, so it can click around and wait for JavaScript to
finish populating the page;&lt;/li&gt;
&lt;li&gt;it&#39;s not affected by &lt;code&gt;robots.txt&lt;/code&gt;, because it&#39;s a real (and heavy) browser
rather than a lightweight crawler;&lt;/li&gt;
&lt;li&gt;it&#39;s free and open source, Apache-2.0;&lt;/li&gt;
&lt;li&gt;it can be self-hosted, so nobody else gets your browsing history (or,
better said, your assistant&#39;s 🙃).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&#39;s a bonus I didn&#39;t expect. Instead of screenshots, the default output is
an accessibility snapshot: a compact text tree of what&#39;s on the page, with
reference ids the model can act on. It&#39;s far smaller than raw HTML and it
turns &quot;fetch this page&quot; into &quot;fetch this page, then click Release notes and
scroll&quot;.&lt;/p&gt;
&lt;h2 id=&quot;how&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#how&quot; aria-label=&quot;Anchor link for: how&quot;&gt;How&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If you have a Raspberry Pi gathering dust (like I do), or something even
geekier, like a Kubernetes cluster built from a stack of Raspberry Pis that
you struggle to find useful workloads for (like I also do), you can run
Playwright MCP completely for free. One container, half a gigabyte of RAM at
rest, arm64 is fine.&lt;/p&gt;
&lt;p&gt;I won&#39;t give step-by-step instructions, because your preferred agent can
figure those out. What follows are the points worth handing to it up front,
the ones that cost me time or nearly cost me more. Or just have it read this
page: it renders without JavaScript and its &lt;code&gt;robots.txt&lt;/code&gt; welcomes everyone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Playwright MCP has no authentication of its own, and Claude can&#39;t add any.&lt;/strong&gt;
The server&#39;s README says plainly that it &quot;is not a security boundary&quot;.
Anyone who can reach the port owns a browser inside your network. You&#39;d think
a bearer token in front would do, but the connector settings in claude.ai and
Claude Desktop accept a URL and, optionally, OAuth. No custom headers, no
static tokens, no client certificates. So the missing piece is an OAuth layer
you don&#39;t have to write.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloudflare&#39;s MCP server portal can be that layer, and its free tier is
generous.&lt;/strong&gt; It&#39;s part of Zero Trust, which is free for up to fifty users. You
register your upstream MCP server with the portal, and the portal publishes it
at a Cloudflare-hosted URL such as &lt;code&gt;https://mcp.your-domain/mcp&lt;/code&gt;. Cloudflare
Access acts as the OAuth provider, so the login flow is exactly what claude.ai,
Claude Desktop and Claude Code expect. On every call the portal adds a service
token to the upstream request, and an Access application on the upstream
hostname admits only that token. The result is that your server is reachable
through the portal and through nothing else. You also get per-tool
enable/disable and a request log for free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cloudflared, another Cloudflare service with a generous free tier, can keep
the server off the internet.&lt;/strong&gt; It opens an outbound tunnel from your network
to Cloudflare, so the upstream hostname resolves to Cloudflare and there is no
inbound port on your router. Point the tunnel straight at the Playwright
service. Don&#39;t route it through a shared ingress controller, for a reason
that&#39;s coming up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Playwright MCP drives a real Chromium, without its sandbox, so give the
container as little as possible.&lt;/strong&gt; The official image runs headless Chromium
with &lt;code&gt;--no-sandbox&lt;/code&gt;, which is normal in containers but means a renderer
exploit lands straight in the container&#39;s user context. Run it as a non-root
user with a read-only root filesystem, all capabilities dropped, no privilege
escalation, the runtime&#39;s default seccomp profile, and no Kubernetes
service-account token mounted, because the server never talks to the API.
Chromium tolerates all of that with three exceptions: it needs a writable
&lt;code&gt;/tmp&lt;/code&gt;, a &lt;code&gt;/dev/shm&lt;/code&gt; bigger than the 64 MiB container default or tabs crash,
and a writable home directory or its crash handler aborts the browser on
launch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Above all, keep it away from your local network.&lt;/strong&gt; Its job is to fetch web
pages from the internet. It has no business reaching anything at home. A
browser will happily open &lt;code&gt;http://10.43.0.1/&lt;/code&gt; and it will follow redirects
there too; Playwright&#39;s own &lt;code&gt;--blocked-origins&lt;/code&gt; flag is documented as not
being a security boundary. This one bit me. My first version was reachable
through the cluster&#39;s ingress controller, which also has an address on the
LAN. Any phone, smart plug or guest on the Wi-Fi could send a request with
the right &lt;code&gt;Host&lt;/code&gt; header, get an unauthenticated browser session, and drive it
wherever it liked. I proved it with one navigate call to my Vault server&#39;s
internal address, which cheerfully returned its health JSON, cluster id and
all. That&#39;s server-side request forgery from anything on the Wi-Fi, and the
carefully locked front door was irrelevant because there was a second door.&lt;/p&gt;
&lt;p&gt;The fix is network policy in both directions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ingress:&lt;/strong&gt; only from the tunnel pod, only on the server&#39;s port.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Egress:&lt;/strong&gt; cluster DNS, then the whole internet with the private ranges
carved out: &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, &lt;code&gt;192.168.0.0/16&lt;/code&gt;,
&lt;code&gt;169.254.0.0/16&lt;/code&gt; and &lt;code&gt;100.64.0.0/10&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then verify it by attacking it. Ask the browser, through the portal, to open
that same internal URL. Before: Vault&#39;s JSON. After:
&lt;code&gt;net::ERR_CONNECTION_REFUSED&lt;/code&gt;. That&#39;s the answer you want.&lt;/p&gt;
&lt;h2 id=&quot;was-it-worth-it&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#was-it-worth-it&quot; aria-label=&quot;Anchor link for: was-it-worth-it&quot;&gt;Was it worth it?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Phoebe now reads Garmin release notes, and everything else I point her at,
including pages that need a login or a scroll. The difference is not speed; a
round trip through Cloudflare to a Raspberry Pi and back takes a few seconds.
The difference is that pages which used to return nothing now return their
content. Given that the whole thing costs nothing beyond electricity for a Pi
that was already plugged in, I&#39;d say the watch firmware was the most
productive boring update I&#39;ve ever installed.&lt;/p&gt;
&lt;p&gt;* Free as in you already own something to run it on and don&#39;t count the
electricity.&lt;/p&gt;
</content>
        
    </entry>
</feed>
