# 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](01-endpoint-index.md) and [Operations (writes)](03-operations.md).

## 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](https://www.bookwithhaven.com/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](../01-oauth/07-dynamic-client-registration.md) at the `registration_endpoint`, and run the [authorization code flow with PKCE](../01-oauth/01-authorization-flow.md). 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_policy`** says 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_calendar`** requires both window bounds and caps the span at 400 days. Bookings, host blocks and a linked calendar group's sibling bookings all arrive in one `blocks` array — 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 at `params._meta["haven/idempotencyKey"]`. Your `arguments` then stay byte-identical to the REST request body.
- **If you cannot** — the normal case for a model filling in a tool schema — pass `idempotencyKey` as 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`:

```json
{
  "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](../03-conventions/02-errors.md).

## 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](../03-conventions/07-rate-limits.md).
