Most guidance on the Model Context Protocol assumes you are building a server in front of a store or a database, something with orders and inventory. We did the less obvious thing: we turned a static marketing site into a live, read-only MCP server. Any MCP client can now POST to a single endpoint and ask what services exist or how to start an inquiry, in typed JSON-RPC, without parsing a single page of HTML.
This is a practitioner writeup of that build. It is deliberately narrow: a handful of read-only tools, no database, no writes. That narrowness is the point, and most of the interesting decisions come from it. What follows is why a content site benefits from an MCP endpoint at all, why the endpoint is a Cloudflare Pages Function rather than a framework route, why we hand-rolled JSON-RPC instead of pulling the SDK, and the one trailing-slash bug that silently breaks every POST if you miss it.
Why put an MCP server on a static marketing site
A store exposes MCP because an agent needs to take an action: check stock, build a cart, place an order. A marketing site has no cart. So what is the tool surface for?
The honest answer is agent discovery. Our site already ran an agent-discovery layer: an llms.txt, a set of .well-known documents, and an MCP server card at /.well-known/mcp/server-card.json. That card described a server, its transport, and its capabilities. It even declared a tools capability. There was one problem: nothing lived behind it. The card advertised a server that did not exist. An agent that followed the card and tried to connect got nothing.
That is a common state for these files. They get generated, they get advertised in a Link header, and then they point at a resource that was never built. The card is a promise with no implementation.
So the goal was not to invent a new surface. It was to make an existing, advertised surface real. The tools follow directly from what a marketing site is for:
check_servicesreturns the service catalogue: id, name, description, category, and canonical URL.request_quotereturns the localized contact URL and instructions to submit an inquiry.
The first release shipped with those two. Three more read-only tools followed, each one a new entry in the TOOLS array and a new branch in callTool, with no change to the transport:
check_tech_stackreturns the technology stack we work with, grouped by category, with an optional category filter.get_case_studiesreturns the case studies with their stack, filtered by an optional search term.search_blog_postssearches the blog by title, description, categories, tags or slug, with optional language and category filters and a result limit.
Five tools, all read-only. An agent can now enumerate what we do and hand a user the right next step, deterministically, instead of guessing from prose.
How to run an MCP server as a Cloudflare Pages Function
The site is built with Astro in static output mode. There is no SSR adapter. Every page is prerendered to HTML at build time, and the API-style files it serves (the service catalogue JSON, the agent profile) are static files under public/, not server routes.
That single fact drives the whole design. A dynamic endpoint that reads a request body and returns a computed response cannot be an Astro route here, because there is no server to run it. Adding one would mean switching the output mode to hybrid or server and attaching an adapter, which is a large, risky change to a build that already produces thousands of pages.
The site does, however, already run hand-written Cloudflare Pages Functions: a middleware that handles redirects, and a few small API handlers. That is the seam. A dynamic MCP endpoint is just one more Pages Function, functions/mcp.ts, sitting next to the others. It deploys with the normal build, needs no adapter, and changes nothing about how the pages are rendered.
The lesson generalizes. When you want to add one dynamic endpoint to an otherwise static site, do not reach for the framework’s server mode. Reach for whatever edge-function primitive your host already gives you. On Cloudflare Pages that is a Function. The cost is one file, not a rebuild of your rendering model.
How to build an MCP server without the official SDK
The reflex when you read “MCP server” is to install @modelcontextprotocol/sdk. For a stdio server with many tools, resources, and prompts, that reflex is correct. For a few read-only tools on an edge function, it is not.
The MCP wire protocol is JSON-RPC 2.0. A server that answers an MCP client needs to handle a small set of methods:
initialize, where the server returns its protocol version, capabilities, and identity.tools/list, where it returns the tool definitions with their JSON Schema.tools/call, where it runs a named tool and returns the result wrapped in MCP content.- notifications like
notifications/initialized, which carry no id and expect no response.
That is the entire surface for a read-only server. Implementing it by hand is roughly 120 lines. The dispatch is a plain switch:
switch (method) {
case "initialize":
return ok({ protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo });
case "tools/list":
return ok({ tools: TOOLS });
case "tools/call":
return ok({ content: [{ type: "text", text: callTool(name, args, services).text }] });
default:
return err(-32601, `Method not found: ${method}`);
}Weigh that against the SDK. The SDK is designed for stdio and for the full protocol. On an edge runtime you inherit its bundling assumptions and its transport machinery for features you are not using. For a few read tools, a dependency you have to reason about is a worse trade than 120 lines you fully control. This is the same read-only-first restraint applied to dependencies: expose the minimum, own the minimum.
The one thing worth doing carefully by hand is the error contract. JSON-RPC has defined error codes, and MCP clients expect them: -32700 for a parse error, -32601 for an unknown method, -32602 for bad params. Getting those right is what makes a hand-rolled server behave like a real one to a strict client.
Why a public MCP server should be read-only
The tempting next tool is one that captures a lead: take a name, an email, and a message, and email it to us. Do not build that as an open MCP tool.
A public endpoint with a writable tool is a spam sink. Anyone on the internet can call tools/call with submit_quote and a fake payload, and now your inbox, your CRM, or your database is a target with no friction and no human in the loop. The moment a tool writes, the endpoint needs authentication, rate limiting, and abuse handling, which is a large surface for a marketing site to defend.
So request_quote writes nothing. It returns the localized contact URL and structured guidance:
if (name === "request_quote") {
const lang = LOCALES.includes(String(args?.lang)) ? String(args.lang) : "en";
return { text: JSON.stringify({
contact_url: CONTACT_URLS[lang],
method: "web-form",
note: "Read-only endpoint. Submit the inquiry through the contact form; this tool does not send it for you.",
reply_time: "within one working day",
}, null, 2) };
}The agent gets everything it needs to move the user forward: the right contact page for the user’s language, and a clear statement that submission happens through the form. The human stays in the loop. The endpoint has nothing to abuse.
This is not a weaker design forced by caution. It is the same stance we recommend to clients building MCP for their own systems: read-only first, add writes only behind authentication when there is a concrete reason. A read-only server is honest about what it is, and it is safe to leave open.
| Design choice | Read-write open tool | Read-only hand-off |
|---|---|---|
| Auth required | Yes | No |
| Spam surface | High | None |
| Human in the loop | Optional | Always |
| Code to defend | Rate limit, validation, abuse handling | None |
| Fit for a public marketing site | Poor | Good |
Why a trailing slash redirect drops the POST body
This one cost real debugging time, so it is worth stating plainly.
Our site canonicalizes URLs to a trailing slash. The middleware 301-redirects any path without one to the slashed version. That is fine for pages. It is a silent disaster for a JSON-RPC endpoint.
A POST /mcp hits the redirect and returns 301 to /mcp/. Many HTTP clients, following the redirect, do not resend the POST body, or downgrade the method. The MCP client sees an empty or failed response and concludes the server is broken. The server is fine. The request never arrived with its body.
The fix is not to fight the middleware. It is to advertise the URL the site actually serves. The MCP server card and every discovery reference point at /mcp/, with the trailing slash, so a well-behaved client POSTs directly to the canonical URL and never touches the redirect. If your framework or host normalizes slashes, decide which form is canonical and advertise exactly that form everywhere.
You can verify the live endpoint the same way we do:
curl -s -X POST https://wppoland.com/mcp/ \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'A slashless POST to the same host will show you the 301 instead.
How an MCP server helps GEO and AI agent discovery
Generative engine optimization is mostly about being legible and verifiable to models. Structured data, clear entities, consistent claims across your own surfaces and external ones. A live MCP endpoint is a strong version of that legibility: it is not a hint about your content, it is a callable interface to it.
Two things make it worth the small effort even while adoption is early. First, it closes the gap between what the discovery layer advertises and what exists. A card that points at a working server is a coherent signal; a card that points at nothing is a broken one, and broken signals are worse than absent ones. Second, for anyone who sells this capability, the endpoint is proof. We build MCP servers for clients, and the most credible demonstration of that is a public, read-only one running on our own domain, next to the open-source WooCommerce MCP server we publish.
No major AI provider formally commits to reading MCP server cards or llms.txt today. That is a fair caveat and we state it plainly. The endpoint is cheap to run, it makes an advertised promise true, and it is a working artifact rather than a claim. Those three together clear the bar.
Another site’s MCP telemetry, October 2026: Amir Pournasserian counted the traffic on his own site’s MCP server for three and a half days (14 to 18 October 2026). Clients opened about 300 connections (308 initialize, 281 tools/list) and made 29 tool calls, 15 searches and 14 fetches, and many of them named themselves as MCP or agent directories and rankers. They also asked for about a dozen discovery addresses his server does not have, /.well-known/mcp/server-card.json among them, which he traces to earlier Server Card drafts. That is the address our card uses, linked from our /.well-known/ai-catalog.json. The MCP Server Cards proposal, SEP-2127, now marked Final, allows a card at any unreserved address an AI Catalog points to and recommends <streamable-http-url>/server-card. He found no mainstream client that follows a catalog on its own: Claude Code, Claude.ai, VS Code and Cursor take the server URL from the user or from their own gallery. One personal site and three and a half days of data. Most clients fetched the tool list, and few called a tool.
Which MCP features a small server can skip
A short list, because knowing what a build skips is as useful as knowing what it includes.
- No tool added ahead of a question.
get_case_studieswas missing from the first release, and agents read the case studies fromllms.txtand the linked pages until the tool arrived. Each tool on the list now answers a question an agent actually asks. - No
resourcesorpromptscapability. The server declares onlytools, because that is all it implements. Declaring a capability you do not serve is worse than omitting it: a client that callsresources/listgets an error instead of a clean “not supported”. - No SDK, as covered above.
- No writes, as covered above.
Each omission is a decision, not an oversight. The endpoint does exactly what its read-only tools require and nothing more, which is why it is small enough to reason about in one sitting and safe enough to leave open to the internet.
What the MCP spec requires from a Streamable HTTP endpoint
The transport behind POST /mcp/ is Streamable HTTP. The MCP transports specification (version 2025-06-18) defines it as the replacement for the HTTP+SSE transport of version 2024-11-05. Most of its rules deal with streaming, and a read-only server can meet them without streaming anything.
- Every JSON-RPC message from the client is a new HTTP POST to one endpoint path. The client must send an
Acceptheader that lists bothapplication/jsonandtext/event-stream. - When the POST body is a notification or a response, the server must answer
202 Acceptedwith no body. Our function does that fornotifications/initialized. - When the body is a request, the server chooses: open an SSE stream, or return one JSON object with
Content-Type: application/json. The client must handle both, so a server with nothing to stream can always answer with plain JSON. - Sessions are optional. A server may assign an
Mcp-Session-Idheader in the response toinitialize, but nothing forces it to. Without a session ID there is no session store to keep, which suits an edge function that holds no state between requests. - After initialization, the client must send an
MCP-Protocol-Versionheader on every request. If the header is missing and the server has no other way to tell the version, it should assume2025-03-26. An invalid or unsupported value must get400 Bad Request.
The same page carries a security warning for this transport. Servers must validate the Origin header on all incoming connections, servers running locally should bind to 127.0.0.1 rather than every network interface, and servers should authenticate connections. The attack the spec names is DNS rebinding: a remote website using the visitor’s browser to reach an MCP server on their own machine.
How MCP protocol version negotiation works
Version negotiation happens inside initialize, and the lifecycle specification keeps it short. The client must send a protocol version it supports, and it should be the latest one it supports. If the server supports that version, it must answer with the same version. If not, it must answer with another version it does support, again preferably its latest. A client that does not support the version in the reply should disconnect.
Protocol versions are date strings such as 2024-11-05, 2025-03-26 and 2025-06-18. Our function uses 2025-06-18 as its default. For a hand-rolled server the rule comes down to one decision: the list of versions you answer for. The lifecycle page shows the error shape for a mismatch: code -32602, the message “Unsupported protocol version”, and a data object with the supported and the requested versions.
Once negotiation is done, both sides must respect the negotiated version and use only capabilities that were negotiated. That is the formal reason the server above declares only tools: a capability in the initialize reply is a promise the client is allowed to act on.
How to mark an MCP tool as read-only
The MCP schema gives each tool an optional annotations object with four behaviour hints:
| Hint | Meaning | Default |
|---|---|---|
readOnlyHint | the tool does not modify its environment | false |
destructiveHint | the tool may perform destructive updates; meaningful only when readOnlyHint is false | true |
idempotentHint | repeated calls with the same arguments have no additional effect; meaningful only when readOnlyHint is false | false |
openWorldHint | the tool may interact with an open world of external entities | true |
The defaults are pessimistic. A tool without annotations is described as one that may write, may destroy data and may reach outside its own domain. The tools in this build ship without annotations, so the hints alone would describe them that way. Adding readOnlyHint: true to each tool definition is a one-line change.
The schema also says these properties are hints and not a faithful description of behaviour, and the tools specification says clients must treat annotations as untrusted unless they come from trusted servers. A hint never replaces the control. The read-only property of this endpoint lives in the code, where no tool has a write path. The annotation only describes that property to a client that decides to trust it.
The tools specification sets three duties for every server: validate all tool inputs, implement proper access controls, and rate limit tool invocations. Our function covers the first one by rejecting malformed arguments with -32602. The spec also separates two kinds of failure: protocol errors such as an unknown tool go back as JSON-RPC errors, while a tool that ran and failed returns a normal result with isError: true, so the model sees the failure text and can react to it.
How to connect Claude Desktop, Cursor and Codex to an MCP server
The endpoint works with any MCP client that supports Streamable HTTP. Each client keeps remote servers in a different place, so the setup differs per client.
Claude Desktop
The remote server guide on modelcontextprotocol.io adds remote servers to Claude as Custom Connectors. Open Settings, select Connectors, click Add, choose “Add custom connector” and paste https://wppoland.com/mcp/. In the same Connectors screen you can enable or disable individual tools of a connected server.
Cursor
Cursor reads .cursor/mcp.json in a project, or ~/.cursor/mcp.json for every project (Cursor MCP documentation). A remote server uses the url key:
{
"mcpServers": {
"wppoland": {
"url": "https://wppoland.com/mcp/"
}
}
}OpenAI Codex CLI
Codex keeps MCP servers in config.toml, by default ~/.codex/config.toml (Codex MCP documentation). The entry is a table named after the server, [mcp_servers.wppoland], with one line under it: url = "https://wppoland.com/mcp/".
Generic MCP client
Any client that supports Streamable HTTP can POST JSON-RPC to the endpoint directly. The curl call earlier in this post is the smallest working example.
More guides on building MCP servers
For an end-to-end operational walkthrough of configuring, testing, and debugging this specific server with JSON-RPC schemas and Claude Desktop, read our WPPoland live MCP server guide. If you want the fuller version of these decisions, the cluster around this post covers the adjacent ground: building an MCP server for WooCommerce for the stateful, store-facing case, MCP vs REST: when each wins for whether you need MCP at all, and MCP authentication patterns for the moment you do add a writable tool and need auth. The commercial version of this work lives on the MCP server development service page.
The short version fits in a sentence: a marketing site can be an MCP server, the endpoint is one edge function, keep every tool read-only, and advertise the URL your host actually serves.






