MCP endpoint
POST https://www.bookwithhaven.com/api/public/mcp
A Model Context Protocol server over the whole Haven API — every read as a tool, every write as a tool, plus the search/fetch pair.
It is not a second API. A read tool runs the same registry entry GET /api/public/v1/… runs; a write tool builds a request against /api/public/v1/operations/{operation} and hands it to the same dispatcher. 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.
Tool names are the endpoint names. get_listing is GET /properties/{code}; block_dates is POST /operations/block_dates. See Endpoint index and Operations (writes).
A second, anonymous MCP endpoint: traveler stay discovery
POST https://www.bookwithhaven.com/api/stays/v1/mcp
Everything above is the host-facing server: it acts inside one Haven account and requires OAuth. If you are building an assistant that recommends stays to travelers — "a villa in Coral Bay with walkable beach access and a private pool" — use the stays endpoint instead. It requires no authentication, is read-only, and serves only the public catalog a signed-out visitor to /stays sees.
Its tools: the search/fetch pair (the shapes ChatGPT connector surfaces require), search_stays (the full AI search — match tiers, real availability for concrete dates, price totals, a grounded concierge note and per-stay why-lines), browse_stays (the catalog, listed), and list_destinations (every place with bookable stays, each with a landing-page URL). Results carry each stay's real booking URL on the host's own site. Same transport rules as this server (stateless, one JSON-RPC message per POST, notifications answered 202), same per-IP rate limit as /api/stays/v1/search.
Connecting
Point your client at the URL. Authorization is discovered from it.
An unauthenticated request answers 401 with:
WWW-Authenticate: Bearer realm="Haven", resource_metadata="https://www.bookwithhaven.com/.well-known/oauth-protected-resource/api/public/mcp"
From there a client with no configuration can complete the whole flow on its own: read the protected-resource document, read the authorization-server metadata it names, register itself at the registration_endpoint, and run the authorization code flow with PKCE. A Haven host signs in, sees what the connection will be able to read and what it will be able to change, and approves it. Nothing is queued for review.
The MCP endpoint is its own protected resource, described at /.well-known/oauth-protected-resource/api/public/mcp; 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 resource against the server it is calling gets a match either way. RFC 8707 resource indicators are accepted and validated against those two values on both the authorization request and the token request.
Send the resulting hvat_ access token as Authorization: Bearer … on every POST.
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. GET and DELETE answer 405 with an Allow: POST, OPTIONS header. 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.
Protocol revisions 2025-06-18, 2025-03-26 and 2024-11-05 are negotiated; a client asking for something else is answered with the version this server speaks rather than refused. MCP-Protocol-Version is echoed on every response. OPTIONS preflight and CORS are enabled, so a browser-based client works.
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 is one the scope gate will admit; one 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.
Reads
Seventeen read tools, one per REST read endpoint, all readOnlyHint: true.
Arguments are flat: the path parameter and the query string merged into one object. get_listing takes { code }; get_listing_calendar takes { code, from, to }. The schemas are the same Zod schemas the REST route parses with, .strict() included — an unknown key is the same 400 naming the key, not a silently different answer.
Start with get_account. It says which Haven account the connection acts inside, which scopes it holds and whether it can write at all.
Two reads are worth knowing about before you write:
get_listing_sync_policysays which parts of a listing a connected property management system owns. A write into a locked domain is refused; one that is not refused may still be reverted by the next inbound sync. Read this before offering an edit.get_listing_calendarrequires both window bounds and caps the span at 400 days. Bookings, host blocks and a linked calendar group's sibling bookings all arrive in oneblocksarray — treat all three as occupied.
list_reservations and list_conversations each require property_code or updated_since. That is not authorization — every row is already the host's — it is because the unbounded answer is a full history walk. Both together is the incremental-sync shape.
search and fetch
A convenience pair over the same reads, for clients that look for those two names.
search takes { query }. A Haven reference code is resolved directly across listings, reservations and message threads. Free text is matched against listing titles and discount-code names — the two collections this API will enumerate without a required filter. It returns { results: [{ id, title, url }] }.
Reservations and message threads are not searched by free text, for the same reason their list endpoints require a filter. Reach them by code through search, or through list_reservations / list_conversations with property_code or updated_since.
fetch takes { id } from a search result — "listing:AB12CD34" — and returns { id, title, url, text, metadata }, where text is the record as JSON.
Neither tool has any authority of its own. Every read they perform goes through the same dispatcher and is charged the same read budget.
Writes, and how idempotency works 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 — the normal case for a model filling in a tool schema — pass
idempotencyKeyas an ordinary argument. It is declared on every write tool and 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.
Read tools carry no idempotency envelope. A read needs no key.
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, and the text block carries it too — structuredContent only reached MCP in the 2025-06-18 revision, and a client on an older one would otherwise read "send this again with the token" and have no token. 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, a missing path argument.
Read code, not message. The taxonomy is in Errors.
Rate limits
tools/list and every read tool are charged the read budget. Every write tool is charged the write budget and its operation family's budget. The limiter fails closed: a tools/list Haven cannot account for is refused rather than served. See Rate limits.