SHEET B-06  /  FIELD NOTES  /  ENTRY 05

Adding an MCP Server to Your Portfolio Site

Field Notes | Build Log

Illustration accompanying the post

Backed by unpopular demand, there is now an MCP server running against this site. Point an agent at it and ask what I have built, what I have written about scoping, or what I think about identity in a hybrid environment, and it will answer out of my own posts and work history. Amaze!

The server itself is straightforward: three read-only tools reading a content.json generated from the blog posts at build time. What took the evening was five separate things going wrong, none of which appear in any tutorial, because the spec they relate to shipped eleven days ago. This is that list. insert Law & Order bum bum.

Setup, for context: GitHub Pages serves this site, which is static, so an MCP server has to live somewhere with compute. Cloudflare Workers, free tier. No authentication, because everything it serves is already public. Putting OAuth in front of a copy of public HTML is architecture theater (and I'm not talking about set design.)

0. The shape of it

Before the list of things that went sideways, here is the build itself. It is short enough that the problems really are most of the story.

The content pipeline. This is the design decision worth stealing. Rather than having the Worker scrape my HTML at request time, or maintaining a second copy of my bio by hand, a build script walks the blog files, pulls the title out of the h1, the outline out of the section headers, and the body text out of the article paragraphs. It merges that with a block of roles and projects maintained at the top of the script, and writes content.json to the site root.

GitHub Pages then serves that file like any other static asset. The Worker fetches it once and caches it for the life of the isolate. One source of truth, nothing to keep in sync, and if the Worker disappears the site is completely unaffected. It also means the Worker holds no data of its own, which is the thing that makes the whole design boring in the good way.

Layout. The project lives in an mcp/ folder inside the same repo. Pages ignores it, so nothing about the static site changes:

professional-website/
  index.html
  blogs/
  content.json          generated, committed, served by Pages
  mcp/
    package.json
    wrangler.jsonc
    src/
      index.js          routing, two lanes
      tools.js          tool definitions, shared by both
    scripts/
      build-content.js

The Worker config is four lines that matter. Name, entry point, a compatibility date, and nodejs_compat, which the SDK needs:

{
  "name": "info",
  "main": "src/index.js",
  "compatibility_date": "2026-08-01",
  "compatibility_flags": ["nodejs_compat"]
}

Tools. Three, all read-only: search the writing, get the work history, get the detail on a specific project. Each one is a task rather than a route, which matters more than it sounds. If a common question takes an agent four calls in a fixed order to answer, that sequence should have been one tool with a name that says what it does.

The descriptions are the actual interface. An agent cannot walk over and ask a coworker what a field means, so the description and the response shape are all it has:

server.registerTool(
  "search_writing",
  {
    description:
      "Search Taylor Treece's technical writing by keyword or topic. " +
      "Returns matching posts with title, URL, section outline, and a " +
      "relevant excerpt. Use this for questions about his opinions, " +
      "technical approach, or how he thinks about a problem.",
    inputSchema: {
      query: z.string().describe("Keyword or topic, e.g. 'MCP identity'"),
      full_text: z.boolean().optional()
        .describe("Return the whole post instead of an excerpt."),
    },
  },
  async ({ query, full_text = false }) => { /* ... */ }
);

Those definitions live in tools.js and export a single registerTools(server) function. Both lanes call it. More on why that matters in item 5.

Auth. None, for the reason above. Worth being explicit that this is what makes the rest of the build small: no OAuth flow, no token exchange, no resource indicators, no identity provider to integrate. A server that writes anything, or reads anything private, is a different project entirely, and most of the specification's complexity lives in that other project. Know which one you are building before you start.

The sequence. The ordering constraint here is the only non-obvious part: content.json has to be live on the site before the Worker can fetch it, so the push comes before the deploy.

node mcp/scripts/build-content.js   # generate content.json
git add . && git commit -m "..." && git push

cd mcp
npm install
npx wrangler login                  # one time, opens a browser
npx wrangler dev                    # local, hot reload
npx wrangler deploy

Verifying it. Curl is the fastest confidence check, and the only client that will not lie to you:

curl -s https://your-worker.workers.dev/mcp -X POST \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Then call a tool, which is the real test since it exercises the content.json fetch rather than just the protocol handshake:

-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
     "params":{"name":"search_writing",
     "arguments":{"query":"MCP identity"}}}'

One maintenance note. Regenerating content.json belongs in whatever you use to commit, otherwise it drifts from your posts and the server quietly starts answering out of date. I put it in the shell function I use to commit and push, gated on the build script existing, so it is a no-op in every other repo.

That is the whole build. Under an hour. What follows is the rest of the evening.

1. The template everyone links to is deprecated

Search for how to build a remote MCP server on Workers and you will find the same instructions repeatedly: run npm create cloudflare@latest -- --template=cloudflare/ai/demos/remote-mcp-authless, extend the McpAgent class, deploy. Blog posts from March and April, a few from May, all saying the same thing.

Cloudflare's own docs now say not to do that. From the current guide, verbatim in a callout: the quick-deploy templates still use the deprecated McpAgent path, and you should not use that path for a new server. McpAgent is deprecated and feature-frozen. Cloudflare's own comparison table lists it as the stateful, legacy-protocol option, which is the shape the 2026-07-28 spec moved away from.

The current API is createMcpHandler from agents/mcp/server. It creates a fresh server per request, which is the whole point of a stateless protocol.

The install is different too:

npm i agents @modelcontextprotocol/server@2.0.0 zod

Note the package. @modelcontextprotocol/server is SDK v2. @modelcontextprotocol/sdk is v1, the legacy one. Both will end up in your node_modules because agents pulls v1 in as a peer, so it is entirely possible to import from the wrong one and end up on the deprecated path without noticing. Check your imports.

2. zod v4 versus zod v3

Assemble a package.json by hand from an older example and npm install fails outright:

npm error ERESOLVE unable to resolve dependency tree
npm error Could not resolve dependency:
npm error peer zod@"^4.0.0" from agents@0.20.1

agents@0.20.1 requires zod v4. Worth understanding why npm lands where it does: @modelcontextprotocol/sdk@1.30.0 arrives as a peer of agents and accepts ^3.25 || ^4.0, so npm settles on v3 and then fails against the stricter constraint from agents itself.

Pin ^4.0.0 and reinstall clean. Resist --legacy-peer-deps here. That would install a zod the agents package cannot actually use, and you would trade a clear install error for a confusing runtime failure somewhere inside schema validation.

Worth knowing: the tool definitions in this server needed no changes, since z.string(), z.boolean(), .optional(), and .describe() are unaffected. Do not read that as a free upgrade generally. Zod 4 is a real major release with breaking changes, including a renamed .superRefine(), error.errors becoming error.issues, ZodError no longer extending Error, and different .default() behavior on optional fields. Simple tool schemas happen to sit in the safe part of that surface.

3. 406 on the obvious curl

Minor, but worth knowing before it costs you a few minutes. Curl the endpoint the obvious way, with just a JSON content type, and this comes back:

{"jsonrpc":"2.0","error":{"code":-32000,
  "message":"Not Acceptable: Client must accept both
  application/json and text/event-stream"}}

Streamable HTTP requires the client to accept both content types, so a bare content-type: application/json POST gets rejected before it reaches your code. The header you need:

-H 'accept: application/json, text/event-stream'

This one is worth internalizing because curl ends up being the only reliable client you have while everything else is failing for unrelated reasons.

4. The TLS handshake failure that is not your fault

My first deploy landed at taylor-treece-mcp.taylor-treece-mcp.workers.dev, which is what happens when your account subdomain and your Worker name are the same string. I changed the account subdomain, renamed the Worker, redeployed, and got this:

curl: (35) OpenSSL/3.0.13: error:0A000410:SSL routines::sslv3
alert handshake failure

DNS resolved. TCP connected. TLS died. Nothing was misconfigured: Cloudflare provisions a wildcard certificate for the new *.subdomain.workers.dev and that takes time. Fifteen minutes later it worked.

Two things worth doing here. Use verbose curl rather than -s, which suppresses exactly the error message you need and leaves you staring at empty output. And keep the old Worker deployed until the new hostname answers, so you have a working fallback while the certificate provisions.

5. The 405 that breaks older clients

This is the interesting one, and it is a real architectural consequence rather than a papercut.

Streamable HTTP is not all POST. Legacy clients open a long-lived GET on the endpoint for the server-to-client SSE stream. Cloudflare's stateless compatibility lane documents its limits plainly: each POST creates a new server and transport, no MCP session ID persists, and HTTP GET and DELETE return 405.

So a legacy client POSTs initialize, gets a valid response through the compatibility lane, then opens the GET, receives a 405, and waits forever for a stream that will never exist. From the user's perspective the client just says "Connecting" and never stops.

Confirming it is one command:

curl -i -X GET https://your-worker.workers.dev/mcp \
  -H 'accept: text/event-stream'

A 405 there means every session-based client will hang against your server.

The fix Cloudflare documents is a second lane. createLegacyMcpHandler serves an SDK v1 server over WorkerTransport with real session semantics. I run both:

/mcp          stateless, current spec
/mcp-legacy   session transport, older clients

The part worth doing carefully is keeping one set of tool definitions. registerTool has the same signature on the v1 and v2 servers, so the tools live in their own module and get registered onto both. Two lanes, one definition, no drift. If you copy-paste the tools into both servers you will edit one and forget the other, probably within a week.

One constraint: the legacy server must be constructed per request rather than hoisted to module scope. One server cannot reconnect to several transports.

6. The dev tool that swallows the request

Six items on a list of five, and this is the one that cost the most time.

MCP Inspector would not connect. Not to localhost, not to the deployed URL, not to either lane. It sat at "Connecting" indefinitely while curl against the exact same endpoints returned correct responses every time.

The working theory was that the Inspector had not caught up to the stateless spec. Tidy, plausible, and wrong. What settled it was npx wrangler tail, left running while hitting connect. Zero requests. The Worker was never contacted at all.

The tell was in Cloudflare's own testing guide, which I had already read: their instructions show the Inspector serving on port 5173. Mine was on 6274. That mismatch was the hint that I was running something other than what the docs were written against.

Worth knowing how the Inspector is put together, because it explains what the browser devtools show you. It runs two processes: the client UI, and a separate proxy that actually talks to your server on the UI's behalf. So every request in the network tab goes to localhost, and the outbound call happens somewhere you cannot see from the browser. In my case the UI posted to the proxy, got a 200, opened an events?sessionId=... stream, and then nothing further ever reached my Worker.

npx @modelcontextprotocol/inspector@latest gave me v2.1.0, which failed. Pinning the version the docs are written against worked immediately:

npx @modelcontextprotocol/inspector@0.14.0

The lesson is not about the Inspector. It is that when a client fails and curl succeeds, the fastest path to an answer is proving whether your server was contacted at all. wrangler tail answers that in about ten seconds, and it turns "something is wrong somewhere" into "the request never left the client," which is a much smaller problem.

What I would tell someone starting tomorrow

Read Cloudflare's Build a Remote MCP server and Test a Remote MCP Server guides before any blog post, mine included. They are current, they are dated, and the deprecation callouts are the thing search results will not tell you.

Keep curl in the loop the entire time. It is the only client that will not lie to you while you are debugging four other things at once.

Run wrangler tail the moment a client misbehaves. Knowing whether a request arrived splits the problem space in half.

And build the two lanes. It is maybe thirty extra lines with shared tools, and it means the installed base of clients that have not migrated can still reach you. Given how much of this ecosystem shipped in the last two weeks, that is not a temporary concern.

Which is the ratio I would expect from anything a spec revision touched eleven days ago: the build is the easy part, and the ecosystem around it is where the evening goes.

References

Everything above traces back to one of these. Listed because search results on this topic are mostly older than the thing they describe, and knowing which sources are current saves more time than any of the individual fixes.

The 2026-07-28 Specification
The release post from the MCP working group. Statelessness, the removal of the initialize handshake and session header, the new Mcp-Method and Mcp-Name headers, MRTR, and the authorization changes. Start here.

Cloudflare: Build a Remote MCP server
Where the deprecation callout lives. Also has the comparison table showing which handler to use for which situation, which is the fastest way to understand why McpAgent is the wrong starting point now.

Cloudflare: MCP handler APIs
The reference I actually built against. createMcpHandler and createLegacyMcpHandler, the full options table, the factory lifecycle, and the documented limits of the stateless compatibility lane, including the 405 on GET.

Cloudflare: Test a Remote MCP Server
Inspector, the Workers AI Playground, and client configs for Claude Desktop, Cursor, and Windsurf. The port number in their Inspector example is what eventually pointed me at a version mismatch.

Cloudflare: Migrate to MCP SDK v2
For anyone with an existing McpAgent server rather than a new build. Covers dual-era routing and the staged rollout.

MCP Inspector
The README documents the two-process design: client UI on 6274, proxy on 6277. Useful context for reading your own devtools output when a connection stalls.

Zod 4 migration guide
Breaking changes ordered by impact. Worth a skim before bumping the major, even if your tool schemas turn out to sit in the safe part of the surface.

AWS: How AgentCore Gateway supports the 2026-07-28 spec
Good on why the header changes matter to infrastructure, specifically routing and metering at the HTTP layer without parsing JSON bodies.

Google: Scaling AI agent infrastructure with the MCP stateless updates
Covers the same revision with more attention to the authorization side, including issuer verification and resource indicators.

Back to B-01 / Field Notes
Back to A-01 / Overview