
This article describes how to build a production [Model Context Protocol](https://modelcontextprotocol.io) server on Cloudflare Workers, under the specification's 2026-07-28 revision, which shipped as final two days before this server deployed. The server in question wraps this site's publishing API so that an AI assistant can operate the site as tools in a conversation; [the previous article](/writing/agent-write-access-to-a-live-site) covered the trust model underneath. This one is the build method, and it is organized around four decisions I would recommend to anyone building an MCP server this year: measure your actual client before choosing an authorization design, select your protocol implementation by conformance score rather than preference, isolate any legacy-protocol support in one deletable module with an empirical retirement condition, and keep all business policy out of the server entirely. [The repository is public](https://github.com/DrDustinEdwards/dustinedwards-mcp), so every claim below is checkable.

A dated context note, since MCP is moving. The 2026-07-28 revision is the largest since the protocol launched: it removes the session-based initialization handshake in favor of a stateless model where protocol version and client identity travel in per-request metadata, formalizes servers as OAuth 2.1 resource servers with discovery via Protected Resource Metadata (RFC 9728) and audience binding via Resource Indicators (RFC 8707), replaces dynamic client registration with client identity documents, and introduces a formal deprecation lifecycle with twelve-month windows. Everything below assumes that revision as the design center. Statements about client behavior carry the date they were measured, because they will rot.

## Step 0: no policy in the MCP server

This server contains no policy. Every tool call becomes an authenticated HTTP request to the site's existing operator API, where all rules live: content validation, the publication policy, rate limits on operations, commit attribution. The rationale is covered at length in [the API versus MCP article](/writing/policy-in-the-api-not-the-mcp) and compresses to one sentence here: rules implemented twice are two rule sets, and two rule sets drift.

What this article adds is the enforcement of that constraint, structural and mechanical. Structural: the server is a separate Worker in a separate repository, which means the tempting shortcut, an in-process call into the application that skips the API's authentication and rate limiting, does not exist as an option. The only path to the machinery is the front door. Mechanical: a check script in the build asserts the constraint, no imports from the application codebase, no database binding, no repository credential, with the allowed-bindings list derived from the Worker's own configuration. It currently holds 225 assertions, and its test harness confirms it catches 13 of 13 deliberately planted violations, in keeping with a rule this series applies everywhere: a guard you have never observed failing has not been verified.

## Step 1: interrogate your real client before writing the authorization server

The specification tells you what a compliant client does. It cannot tell you what the client your users actually run does this week, and building to the specification alone risks shipping an authorization flow no real client completes. So the first deploy was not the server. It was a measurement probe: a fake authorization server that walks any connecting client as far as it will go, records every request, and then stops deliberately with a page saying so.

One design property of the probe is worth copying: it had no control plane at all. No endpoint to read captures, no administrative token, nothing to protect or leak; the recorded data was read out of band through the platform's storage tooling. When you must deploy something intentionally insecure-looking, giving it zero credentials and zero management surface is the safest shape it can take.

The captures settled every open question, and the answers are dated 2026-07-29. The consumer client completes the full modern authorization walk: resource metadata discovery including the path-scoped variant, PKCE with S256, audience-bound token requests per RFC 8707, and client identification by metadata document rather than dynamic registration, which meant an entire registration endpoint did not need to exist. Both target clients, however, still open with the previous revision's initialization handshake rather than the new stateless entry, which converts legacy support from a courtesy into a load-bearing requirement, with a retirement condition the same probe can answer later. One anticipated risk did not materialize: strict audience matching accepted the client's path-qualified token requests, so a permissive matching flag I had held in reserve stays unset. I would generalize that last habit: do not loosen a security control preemptively on a guess; wait for the evidence that you must.

## Step 2: choose the protocol implementation by conformance score

I wanted to hand-roll the protocol layer. A dependency-free server is auditable end to end, and a five-tool wrapper seemed small enough to justify it. The conformance scenarios disagreed, and I am reporting the numbers because losing to a scoreboard is the informative way to lose: my dependency-free draft scored 0 of 8 on the new stateless-protocol suite and 3 of 8 on header validation, where the platform's maintained library (Cloudflare's agents package with the official SDK) scored 24 of 28 and 13 of 13.

The miss that best explains the gap is instructive about the revision itself: per-request protocol metadata belongs inside the message's params object, not at the JSON-RPC top level, and a top-level version field is not a versioned message but a malformed one, rejected as invalid. My own test harness made that exact mistake while testing for it. This is the class of error conformance suites exist to catch, hand-rolling multiplies it, and the residual gaps in the library path are named rather than hidden: the library currently lacks one required error type from the new revision (my dependency-free draft scored identically on that scenario, which attributes it upstream), and four applicable scenarios are not yet wired into the gate. A conformance baseline that names its exclusions is worth more than a sweep that quietly skips them, and the baseline runs in the build with an expected-failures file that doubles as the machine-readable record of what is deliberately out of scope.

## Step 3: make legacy support a seam you can delete, not a flag you can forget

The library offers legacy-revision support as a configuration default, which would make eventual removal mean flipping a vendor flag and hoping nothing else depended on it. I inverted that: the server rejects legacy traffic at the library level and handles it in one explicitly named module, using the library's request-classification hook. Retiring the old protocol era is therefore deleting one file, after which the modern-only behavior proves itself, and the retirement condition is empirical rather than calendrical: the probe from step 1 stays in the repository as the instrument, and the module goes when it shows the real clients opening with the modern entry. A seam you can delete is a commitment you can verify; a compatibility flag is a commitment you will forget you made.

## Step 4: two credentials: OAuth for identity, bearer token for policy

The server authenticates twice, and keeping the two legs distinct in both code and documentation prevents a whole category of confused debugging. The client leg answers who is operating: an OAuth 2.1 flow, gated on the site owner's identity through an upstream identity provider, re-verified on requests rather than trusted from a session. The API leg answers what operators may do: the same bearer token any raw caller of the publishing API would present, which means the API neither knows nor cares that an MCP layer exists. An identity failure and a policy refusal are different events with different remedies, and conflating the credentials makes them indistinguishable in logs at exactly the moment you need them distinguished.

:::diagram{title="Two credentials, two questions, two different failures" alt="A sequence diagram with four participants: the AI client, the MCP server, the identity provider, and the publish API. The client calls a tool and the MCP server answers with an authorization challenge. The client completes an OAuth walk at the identity provider, which returns an identity restricted to the site owner, and calls the tool again carrying it. The MCP server verifies that identity on the request rather than trusting a session, and a note across those two participants records that this leg answers who is operating and that its failure is a 401. The MCP server then makes the same bearer-authenticated request a script would make to the publish API, and a second note across those two records that this leg answers what operators may do and that its failure is a 403 naming the policy. The API answers either 200 or a refusal carrying its policy name, and the MCP server passes it back verbatim."}
```mermaid
sequenceDiagram
  participant C as AI client
  participant M as MCP server
  participant I as Identity provider
  participant API as Publish API
  C->>M: call a tool
  M-->>C: authorization challenge
  C->>I: OAuth walk
  I-->>C: identity, owner only
  C->>M: call a tool, with identity
  M->>M: verify per request
  Note over C,M: who is operating? 401
  M->>API: the request a script would make
  Note over M,API: what may operators do? 403
  API-->>M: 200, or refusal + policy name
  M-->>C: verbatim
```
The MCP server holds no policy. It verifies identity, then makes the same
request a script would make, and repeats whatever comes back word for word.
:::

Two tool-design practices from this layer that cost little and are usually skipped. Tool descriptions carry the policy: the save tool's own documentation states [the human-reserved first-publication rule](/writing/agent-write-access-to-a-live-site), tells the agent to check a post's publishability field before attempting, and says the refusal is correct behavior. The agent is constrained by documentation before its first call. And refusals pass through verbatim: the API writes good refusal messages, policy name attached, and the wrapper never summarizes or softens them, because a translation layer that paraphrases the lock misinforms the visitor.

## Step 5: verify end to end from the real client, then verify the layer adds nothing

The acceptance sequence, run against production before this article was written: the OAuth walk completed from the real client; both protocol eras answered correctly; the five tools listed with honest annotations (reads marked read-only, deletion marked destructive, which matters because clients build confirmation interfaces from those hints, and lying to them is lying to the user); a draft was created as one atomic attributed commit; the first-publication attempt was refused with the policy named; an edit landed; a save containing a prohibited character was rejected naming line and column; a malformed slug was rejected by the tool's own input schema before any network request, which is the cheap failure you want schemas to buy; and a concurrent burst of 45 requests saw 17 refused with retry guidance, the admitted count matching the window's earlier spending. One field in the save response deserves attention: the draft's answer-index synchronization reported zero documents uploaded, which is [the draft-exclusion rule from the previous article's incident](/writing/agent-write-access-to-a-live-site) made visible in every response rather than asserted in documentation.

The final claim, that the wrapper adds nothing, was verified by running the same operations raw against the API and comparing: identical commits, identical gate messages, identical refusal prose and policy names reaching the caller. For a layer whose entire design goal is transparency, that comparison is the acceptance test, and it is cheap.

## Scrubbing git history before making the repository public

This server's repository is public, both because the code demonstrates the method and because this portfolio's convention is that configuration identifiers stay out of public trees. Getting there surfaced three traps for anyone rewriting git history for the same reason, recorded here because a naive scrub leaves the job half done. The history rewrite deletes your local copy of the very file being protected, so restore it before your next deploy fails. The rewrite's backup references keep the old objects reachable, so a verification pass run before deleting them and expiring the reflog reports failure against a rewrite that worked. And identifiers can hide in commit messages, pasted from tool output, where a tree-only rewrite cannot see them; a message-filter pass is a separate step. One residue is disclosed rather than hidden: pre-rewrite objects remain fetchable by their hashes until the host's garbage collection runs, the exposed content in this case being storage namespace identifiers, which are addresses rather than keys.

## Limitations

The client-behavior findings are dated and will rot as clients update; the retirement condition for the legacy module is empirical and its instrument ships in the repository. The conformance standing is a baseline with named gaps, not a clean sweep. The adds-nothing claim is scoped to the tool surface tested. And the no-policy constraint, this article's spine, is the right design for a wrapper over a service that already has an API with rules; a standalone MCP server over public data has no policy to misplace and can reasonably be simpler than everything described here.

This article closes [the series](/writing/ten-years-on-cloudflare), following [the trust model](/writing/agent-write-access-to-a-live-site). It was drafted by the agent, staged through the tools it describes as a draft with the operator marker on its commit, and published by the one action the server cannot perform.

## Update, August 2026

A month of operation, two external audits of the site behind this server, and the design's central bet paid in the most boring way possible: the MCP server's diff for the entire hardening month is empty. Every fix the audits prompted, rate limits, pacing, a compensation path, landed in the API where the policy lives, and this layer relayed the new refusals verbatim without knowing they were new, which is exactly what a doorbell should do. The token that this wrapper presents to the API also gained a designed lifecycle, hashed storage, a maximum lifetime, overlap rotation, built on a branch and awaiting the operator's cutover; the change is entirely on the API's side of the line, which is the two-credentials split from step 4 doing its job. The bearer token is policy's credential, so its lifecycle belongs to the API, and this server will not need a line of code changed when it rotates.
