Skip to Content
MCP serverProtocol

Protocol

For the person debugging a client that will not connect, or writing one.

Versions

The server implements 2026-07-28 natively and still answers the initialize handshake for 2025-11-25, 2025-06-18. Supported versions, in full: 2026-07-28, 2025-11-25, 2025-06-18.

Supporting only the newest would be correct and useless — a great many shipping clients still open with initialize. Supporting only the older ones would be quietly wrong the moment a client updates. The specification calls a server that does both dual-era and permits both on one endpoint; the era is decided by how the client opens, and every method dispatches to the same handlers either way.

The server identifies itself as sailo, version 1.0.0.

How the era is decided

SignalEra
params._meta carries a protocol versionWhichever that version belongs to
The method is initialize or notifications/*Legacy
The MCP-Protocol-Version header names a legacy revisionLegacy
Anything elseModern

A client that sends neither signal is far more likely to be new and slightly wrong than old, which is why the fallback is modern.

Asking for a version the server does not speak returns error -32022 with a supported list in data — which is the entire negotiation mechanism now that there is no handshake to negotiate in. Pick one from the list and retry.

Transport

POST only. GET and DELETE answer 405 with Allow: POST.

The current revision removed protocol-level sessions and the standalone GET stream, so every request is a self-contained POST carrying its own version, identity and credential. There is nothing to store between calls and nothing to expire — which is exactly the shape a serverless function can serve correctly, and not the shape the older revisions had.

There is no Mcp-Session-Id. An older client that tries a GET or a DELETE falls back correctly on the 405.

Every response carries mcp-protocol-version: <ModernVersion /> and cache-control: no-store, private.

Headers

In the modern era the request must mirror parts of the body into headers, and the server verifies they agree:

HeaderRequiredMust match
MCP-Protocol-VersionAlwaysparams._meta["io.modelcontextprotocol/protocolVersion"]
Mcp-MethodAlwaysthe body’s method
Mcp-NameOn tools/call, prompts/get, resources/readthe body’s name (or uri)

This is a MUST in the specification for a reason that is easy to miss on a hosted server: a load balancer may route on the header while the server executes on the body, and a request where the two disagree is one that can be made to mean two different things in two places. So a mismatch is a refusal — error -32020 — rather than a preference for one source.

Header values may arrive Base64-wrapped as =?base64?…?= when they are not plain ASCII. The server decodes before comparing, as the specification requires; a server that compared the wrapped form would reject every tool whose name is not pure ASCII.

Mcp-Name is required only for the three methods that name a thing. Demanding it everywhere would reject a perfectly conformant tools/list.

Methods

MethodNeeds a keyAnswers
initializeNoThe handshake — legacy clients only
server/discoverNoSupported versions, capabilities, instructions
tools/listYesThe tools this key may use
tools/callYesA tool result
resources/listYes[]
prompts/listYes[]
notifications/*No202, empty body

Anything else is -32601 with a 404 carrying a JSON-RPC body — which the transport specifies precisely so a client can tell “this server does not implement that method” from a bare 404 served by something that is not an MCP endpoint at all.

resources/list and prompts/list are declared in no capability, so a conformant client never asks. A client that probes anyway gets an empty list rather than a 404, which is the friendlier of two correct answers.

initialize is answered before authentication

Deliberately. It is how a legacy client discovers what it is talking to, and a 401 in its place is a dead end it cannot diagnose — several clients report it as “server unreachable”.

Nothing is disclosed: the answer is the server’s name, its version and the fact that it has tools, all of which are public. Every tool call still needs a key.

The version echoed back is the client’s own where the server speaks it, rather than always the newest. A client that asked for 2025-06-18 and was told 2025-11-25 has been handed a dialect it may not implement.

Capabilities

{ "tools": { "listChanged": false } }

listChanged: false is honest: the tool set is a constant in the deployment, so there is no change to notify about. Note that the visible set still varies by the key on the request — a read-only key does not see the write tools — but that is per-request input, not a change over time.

Errors

Two kinds, and the distinction matters to a model.

A JSON-RPC error says the call was malformed and the model can do nothing with it.

CodeMeaning
-32700The body is not valid JSON
-32600Not a single JSON-RPC 2.0 request
-32601Unknown method
-32602Bad params — a missing name, or a tool this key cannot see
-32603Internal
-32020Headers disagree with the body
-32022Unsupported protocol version; data.supported lists what is

A tool errorisError: true on a result — says the call was fine and the answer is a refusal the model can act on. “No contact with that id” is something it can fix by going and finding the right one.

Authentication failures are JSON-RPC errors with a 401 and a WWW-Authenticate: Bearer header. Rate limiting is a 429.

An unknown tool name returns -32602 with a message naming the likely cause — “it may exist but need a key with write access” — because a read-only key genuinely does not have create_contact, and a bare “unknown tool” sends whoever is debugging to look for a typo that is not there.

A tool that throws internally comes back as a tool error with nothing about the cause. A stack trace or a database message here goes straight into a model’s context and from there into whatever it writes next.

Origin

A request carrying an Origin header that is not Sailo’s own is refused with 403, before anything is parsed or authenticated.

The specification makes this a MUST, and it is the DNS-rebinding defence: a page on the open web can make a browser POST here, and while it cannot read a cross-origin response without CORS — and nothing here sends CORS headers — a request that executes is already too far when the tools include writes.

An absent Origin is the normal case, because MCP clients are not browsers.

Limits

A tool call may take up to 60 seconds. A call can fan out to several queries and the platform default is shorter than the slowest legitimate one.

Rate limiting is shared with the REST API — 240 requests a minute per key across both.

Trying it with curl

When a client will not connect, take it out of the loop.

List the tools:

curl -X POST https://api.sailo.store/api/mcp \
  -H "Authorization: Bearer sailo_sk_…" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }
  }'

Call one:

curl -X POST https://api.sailo.store/api/mcp \
  -H "Authorization: Bearer sailo_sk_…" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: get_shop" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "get_shop",
      "arguments": {},
      "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }
    }
  }'

A tools/list that answers here and not in your client is a client configuration problem. One that fails here names its own reason in the JSON-RPC error — and the two most common are a header that does not match the body, and an unexpanded ${VAR} in the bearer token.

Last updated on