An MCP server is the machine surface on check C4 that sounds like the most work, and for a read-only site it is not. Ours is about four hundred lines across three files, carries no dependency on the request path, and exposes five read-only tools through a single POST route. This post describes what it does as the code does it, why it is stateless and unauthenticated, and what the equivalent looks like for a site whose data is a catalogue, a documentation set or a booking calendar rather than a set of scan reports.
What MCP is, in one paragraph
The Model Context Protocol is a JSON-RPC 2.0 protocol between a client that hosts a model and a server that offers it tools, and optionally resources and prompts. The client sends initialize and learns what the server supports, asks tools/list and receives each tool's name, description and a JSON Schema for its arguments, and when the model decides a tool is worth calling, sends tools/call with the arguments and gets a result back. The transport we use is called Streamable HTTP: one URL that accepts JSON-RPC messages by POST. That is the whole shape. Everything else is what you choose to put behind tools/call.
What ours exposes
The server at /mcp offers five tools, all of them views over the same data layer that serves the REST API and the report pages.
| Tool | Arguments | Returns |
|---|---|---|
get_site_report |
domain |
Score, grade, pillar scores, ranked fixes, all 27 checks with evidence |
get_leaderboard |
category?, limit?, offset? |
Published sites ranked by score |
compare_sites |
a, b |
Two sites side by side, check by check |
get_scan_status |
scan_id |
Progress of a scan in flight |
get_rubric |
none | The rubric, its weights, grade bands and changelog |
Each tool carries annotations that say what it is: readOnlyHint true, destructiveHint false, idempotentHint true, openWorldHint false. A client can read those before deciding whether a call needs a person's confirmation.
The descriptions are written for a model deciding whether to call, not for a developer who already knows. The description of get_site_report says it accepts a bare domain or any URL, and that if no report exists a human can request a scan at /scan. The description of get_rubric says to read it to interpret any score or check id. The initialize response carries an instructions string that says the tools are read-only, how a site gets scanned, and where the OpenAPI document and documentation live.
A successful call returns its data twice, as pretty-printed JSON in the content text block and again as structuredContent, so a client that only reads text and a client that parses structure both get it. A failure that the model can act on, such as a domain with no published report, comes back as a result with isError: true and a plain sentence explaining why and what to do, rather than as a JSON-RPC error. Only a tool that actually throws produces an error object.
Why it is stateless
The route accepts POST and nothing else of consequence. There are no sessions: the server never issues an Mcp-Session-Id and never asks for one. A GET returns 405 with a problem body that says the server opens no server-to-client stream. A DELETE returns 405 saying there are no sessions to terminate. A notification, which is a message with no id, gets an empty 202. Batches of messages are handled in one request. The protocol version is negotiated from the MCP-Protocol-Version header or the initialize parameters, accepting 2025-06-18, 2025-03-26 and 2024-11-05 and falling back to the latest. Responses are sent with Cache-Control: no-store and open CORS headers.
Supported methods are initialize, ping, tools/list and tools/call. Resources, prompts, server-initiated streams and sessions are not supported, by design.
This is the simplest conformant shape, and it has a practical consequence: every request carries everything the server needs, so the endpoint works from a serverless function with nothing kept warm, nothing to time out, and nothing to leak between callers. For five read-only tools there is no state worth keeping.
We wrote the core by hand rather than using the official SDK, for three reasons that are all the same reason. The surface is five tools. The SDK's HTTP transport wants a Node request and response pair that a framework route handler does not have. And every dependency on the request path is weight this site would rather not carry, because we are scored on our own rubric and a slow first byte is a finding. The comment at the top of the file says to revisit if the tool list ever grows past one screen, and that remains the plan.
Why it is unauthenticated
Everything the server returns is already on a public page. A report at /site/{domain} and the same report through get_site_report are the same data, with the same evidence, from the same scan. A key would protect nothing and would add an onboarding step that a model in the middle of a task cannot complete.
The one action on this site with a cost is a scan, because it crawls someone else's site. That stays behind the HTML form at /scan, with a rate limit, and works without JavaScript so an agent can still drive it. The MCP server can only poll a scan's status; it cannot start one. Read-only tools need no key, and the write path keeps its own door.
You can call it by hand from a terminal, which is a good way to see exactly what a client sees:
curl https://agentfriendlyrank.com/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
The /docs page gives the configuration snippet for pointing a client at the URL, and the same data described as an OpenAPI document is at /api/v1/openapi.json; publishing an OpenAPI spec for a site that has no API explains why we keep both.
What a marketing site would expose
The tools follow from the questions people already ask an assistant about you.
A catalogue wants list_products with an optional category and limit, and get_product by identifier, returning what the product page's Offer schema already says: name, price, currency, availability, and the URL of the page. A documentation set wants search_docs with a query and get_doc with a path, returning the page text rather than a link to it, because the point is to save the model a fetch. A clinic, venue or restaurant wants check_availability with a location and a date, returning the open slots; the booking itself goes through the form the site already has, with a person confirming it.
A few rules hold across all of them. Read-only first, and for most sites read-only only. One tool per question a person would actually ask, not one per database table. Descriptions that say when not to call as well as when to. Failures as sentences the model can act on. And a matching OpenAPI document, so a client without MCP support is not worse off.
How C4 sees it
Check C4 tests the homepage HTML for an MCP reference, and the pattern is literal: /.well-known/mcp, mcp.json or the string modelcontextprotocol. A link to /mcp on its own matches none of those, and our own homepage does not match this pattern. We score the full five on C4 through the other three surfaces, agents.json, the OpenAPI reference and the documentation link, because any two surfaces is full marks. The server is there for the agents that use it, not for the scan. The agents.json entry for it declares the transport as streamable-http, the authentication as none, and lists the five tool names, so a program that reads the manifest knows what it will find before it connects.
Check your own site
Run the free scan and read the found list under C4 to see which machine surfaces your homepage references. The full definition is in pillar C of the methodology. If you have a catalogue or a documentation set and would like it exposed this way, the implementation service builds read-only servers of exactly this shape.