MCP endpoint
POST https://www.bookwithhaven.com/api/public/mcp
A Model Context Protocol server that exposes every write operation as a tool. It is not a second API. Each tools/call is turned into a request against /api/public/v1/operations/{operation} and handed to the same dispatcher a REST client reaches, so the scope gate, the rate limits, the source-of-truth lock, the confirmation step and the idempotency ledger are the same code running once — there is no path here that routes around a REST limit, because there is no second path.
The tool name is the operation id, verbatim: block_dates in an MCP client is POST /operations/block_dates in this documentation. See Operations (writes) for what each one does.
Connecting
Authorization is the same OAuth 2.1 flow as the REST API — see The authorization flow. Send the resulting hvat_ access token as Authorization: Bearer … on every POST.
A client with nothing but the URL can discover the rest. An unauthenticated request answers 401 with:
WWW-Authenticate: Bearer realm="Haven", resource_metadata="https://www.bookwithhaven.com/.well-known/oauth-protected-resource"
The MCP endpoint is its own protected resource, described at /.well-known/oauth-protected-resource/api/public/mcp, and the REST surface is described at /.well-known/oauth-protected-resource/api/public/v1. Each document names the resource it describes, so a client that checks the resource value against the server it is calling gets a match either way.
What the server supports
initialize, ping, tools/list and tools/call. Nothing else.
It is stateless: no sessions, no Mcp-Session-Id, no SSE stream, and GET is not implemented. Every POST carries its own bearer and is answered on the same connection. Server-initiated messages — sampling, elicitation, roots — are therefore unavailable, and the server advertises no capability that would need them.
JSON-RPC batches are refused. MCP removed them in the 2025-06-18 revision.
tools/list returns only the tools your grant's scopes allow. A tool that appears in the list is one the scope gate will admit; a tool that does not appear is one it would refuse. It is worth calling again after any authorization change — a host can narrow a grant at any time, and the list is the fastest way to see it.
Idempotency, which works differently here
Every write on this API requires an Idempotency-Key, and MCP has no headers. So:
- If you can set
_meta, put the key atparams._meta["haven/idempotencyKey"]. Yourargumentsthen stay byte-identical to the REST request body. - If you cannot — which is the normal case for a model filling in a tool schema — pass
idempotencyKeyas an ordinary argument. It is declared on every tool and is stripped before the operation sees the body. - If you pass neither, one is derived from the operation, your grant and the arguments themselves.
That last case has a consequence worth stating plainly: two calls with identical arguments are treated as one call, and the second returns the first's result rather than writing again. That is the safe default for an agent that may retry, and it is wrong if you genuinely mean to write twice — send an explicit idempotencyKey when you do.
The two tools that move money
issue_booking_refund and adjust_booking_price are two-step, here as everywhere else on this API.
The first call is always refused. Its structuredContent carries data.confirmationToken. Call the tool again with the same arguments and that token — as confirmationToken in the arguments, or at params._meta["haven/confirmationToken"] — and it executes.
Show the amount to a human between the two calls. That is what the step is for. The token is valid for five minutes and only for those exact arguments, so a proposal for one amount cannot be committed as another.
If you let the server derive the idempotency key, the two calls land on the same key automatically because the arguments are the same. If you supply your own, reuse it on the second call; a new key invalidates the token.
Errors
A refusal comes back as a successful tools/call whose result carries isError: true, with the message in content and the details in structuredContent:
{
"isError": true,
"content": [
{ "type": "text", "text": "This listing's rates are owned by …" }
],
"structuredContent": {
"status": 409,
"code": "conflict",
"message": "This listing's rates are owned by …",
"data": { "lockedDomain": "RATES", "pmsOwner": "Guesty" }
}
}
That is deliberate: a refusal on this API is written to be acted on, and a model only sees it if it arrives as tool output. JSON-RPC errors are reserved for messages that were never processed at all — a malformed envelope, an unknown method, an unknown tool name.
Read code, not message. The taxonomy is in Errors.
Reading
There are no read tools. Reads are the REST API at /api/public/v1, and an agent should use them: fetch the listing or the reservation before changing it. See Endpoint index, and Notes for coding agents for the mistakes that cost the most time.