# local-hotel.com MCP server

**local-hotel.com**: hotels near where you're going, with real walking times and live prices. Its name, for people and machines, is local-hotel.com. Walking times are stored measurements only (a hotel we have not measured has none), and a live price is checked for one hotel and one stay.

An [MCP](https://modelcontextprotocol.io) server for searching hotels, finding the
hotels closest to a venue or landmark, and checking the live price of one hotel.
Built for agents: six read-only tools (each annotated read-only and non-destructive),
no auth, no scraping required. It never books: every hotel it returns has a `url`
on local-hotel.com, where the person sees the live price and books.

**We never send a stored, typical or old price.** A price is only ever a live one,
checked just now for ONE stay (`get_hotel_rates`, or every hotel of a list called
with the person's own dates, from one request for the whole place), with the time it was
checked. We ask our booking partner for it; if the check fails or a limit is
reached, there is no price at all and the hotel page is pointed to, where the price is shown.
A list called without dates, `get_hotel_details` and `search_events` carry no prices.

AI assistants may read and cite our pages. For structured data use these tools:
the HTML pages are paginated and locale-dependent. Bulk scrapers are blocked in
`robots.txt`. A page for people, with set-up steps for Claude, ChatGPT and other
apps, is at https://local-hotel.com/mcp.

| | |
|---|---|
| Endpoint | `POST https://local-hotel.com/mcp` |
| Transport | Streamable HTTP, JSON-RPC 2.0 (optional SSE response) |
| Auth | None needed. All six tools are read-only. Connecting an account is optional (see "Connect your account") |
| Liveness | `GET https://local-hotel.com/mcp` with `Accept: application/json` returns name, version, tool list (a browser gets the page for people) |

## Connect

Claude Desktop — `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "local-hotel": { "type": "http", "url": "https://local-hotel.com/mcp" }
  }
}
```

Any JSON-RPC 2.0 client over HTTP works the same way.

## Connect your account (optional)

Everything above works without an account, and that does not change. When sign-in is available for your app, you can
also connect your local-hotel.com account. Connecting is always your choice, and the connector keeps working if you
never do it.

What connecting gives you:

- Prices from the connector become your member prices, the ones you would see signed in on the website.
- One read-only tool, `my_account`. Your assistant calls it to start the connection, and afterwards to check it: it
  says that you are connected, the country prices are checked for (your account's country, or the United Kingdom when
  your account has none: `country_source` says which), your saved currency, your usual guests when you have saved them
  (`usual_guests`: how many rooms and adults, and the ages of any children), and where to disconnect. It gives no
  name, email, booking or payment detail.
- Your usual guests. Save them once in your account (https://local-hotel.com/account#usual-guests) and a price check
  that does not say who is travelling is made for them. The answer says so (`guests_source: "account_usual_guests"`
  and one sentence in `usual_guests_note`), and guests you name in a request always win. Children's ages saved more
  than 12 months ago are not used: the answer says they need confirming (`usual_guests_need_confirming`) and your
  assistant asks who is travelling. The connector never saves or changes your usual guests; you do that in your
  account.

The consent screen asks for one permission, in these words: the app can
see your member prices when it checks hotels for you, your account's country and currency, and your usual guests
(who usually travels, with any children's ages).

A list of hotels for a place is priced for a guest resident in the United Kingdom, for everyone, except a place we
must price through the single-hotel check, where your account's country is used. The price check for one hotel
(`get_hotel_rates`) uses your account's country. When the two differ, a priced list says so
(`list_prices_for_residence`) and the price for one hotel may differ from the list's.

What an app you connect cannot do: book, pay, change or delete anything, or see your email address or your card. It can
only read what you allowed on the consent screen.

How it works: the app sends you to a local-hotel.com page to sign in and press Allow. It then holds a token for your
account. A token lasts one hour and the app renews it quietly; a connection you do not use for 180 days ends and you
are asked to connect again. Each renewal starts the 180 days again.

To disconnect, open Account, then Connected apps, and press Disconnect next to the app. It stops working straight away
and you can connect it again later. Changing or resetting your password also ends every connection.

If you added the connector by hand with no sign-in, remove it and add it again to be offered the connection (an app's
authentication settings cannot be changed after it is added). Command line clients such as Claude Code use the
connector without an account for now.

## Try it with curl

List the tools (no key, nothing to sign in to):

```bash
curl -s https://local-hotel.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Search hotels for a stay, with the guests in plain fields (`adults` in all, one age per child in
`child_ages`, `rooms`). Use the person's own dates; a list called with dates also carries the live
price of its first few hotels, and one called without dates carries none:

```bash
curl -s https://local-hotel.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_hotels","arguments":{"location":"Edinburgh","check_in":"2027-03-14","check_out":"2027-03-16","adults":2,"child_ages":[8],"rooms":1}}}'
```

## The occupancy token

Several tools take `occupancy`, a compact string rather than a guest count,
because rooms and child ages both change the price:

| token | meaning |
|---|---|
| `2A` | one room, two adults (**the default**) |
| `1A` | one room, one adult |
| `2A8C` | one room, two adults and a child aged 8 |
| `2A,1A` | two rooms — one with two adults, one with one adult |

Comma separates rooms. `A` is an adult and `C` a child, with the child's age before the `C`: `8C` is a child aged 8.
Child ages are required, not optional: many properties price by them.

You can also give the guests in plain fields instead of the token: `adults` (the total, shared between the rooms), `child_ages` (one age per child) and `rooms`. The adults are shared between the rooms and the children spread over them; with no `rooms`, as many rooms as the guests need are used, and a group that cannot fit (more than 4 adults or 3 children a room, 4 rooms) is refused rather than priced smaller. For example `adults: 2, child_ages: [8, 5], rooms: 1` is `2A8C5C`. If both are sent, the `occupancy` token wins (except the default `2A` sent back from an earlier answer).

Guests we cannot price are a question, not an error. The answer is an ordinary result (not flagged as an error) with this shape, and no hotels or prices:

```json
{ "reason": "party_not_priceable", "question_for_person": "6 adults do not fit in 1 room (at most 4 adults a room). Ask for more rooms or fewer adults.", "prices_checked": false, "next": "Ask the person the question above, then call this tool again with the guests they give. No hotels or prices were looked up for this request." }
```

Put `question_for_person` to the person, then call the same tool again with the guests they give.

## Tools

### `search_hotels`

Search by city or destination. Returns the best-reviewed bookable matches. Called with the
person's own `check_in` and `check_out`, every hotel in the list that our booking partner's answer for the whole
place includes also carries its live price (see "Town prices" below); without dates a list has no prices.

```jsonc
{
  "location":  "Kuala Lumpur",  // required, 1-100 chars
  "near":      "Petronas Towers", // optional: a landmark the person named; the list is then measured from it (see "A landmark in the town")
  "check_in":  "2026-09-01",    // optional YYYY-MM-DD, defaults to tomorrow
  "check_out": "2026-09-03",    // optional YYYY-MM-DD, defaults to check_in + 2 nights
  "occupancy": "2A",            // optional, see above, defaults to "2A"
  "limit":     10,              // optional, defaults to 10; at most 20 (more than 20 gives 20 and `limit_note` says so)
  "currency":  "GBP",           // optional, ISO 4217: the currency the person wants prices in (see "The person's currency")
  "sort":      "price"         // optional, only with dates: the cheapest hotels of the place when one price check covered it (see "Price order")
}
```

Returns `{ query, source, count, total_in_place?, order, hotels[], note }` (plus `search_url` when `count` is `0`).

**A landmark in the town.** When the person names a landmark, attraction, pier, station or airport as well as the town ("near
Brighton Pier"), send it as `near` beside `location`. The answer is then the `hotels_near_place` answer for it, nearest first by
stored journey time (`answered_as: "hotels_near_place"`, `query.near` echoed), never the town list measured from the centre. If
several places share the name, the one in `location` is taken; otherwise `candidates` come back to choose from. If we hold no place
of that name, the town list comes back with `near_not_found`, which says it is ordered from the town centre and not from that place.

Three arguments that shape the list:

- `limit`: how many hotels, from 1, 10 if left out. A list never has more than 20: ask for more and you get 20, and the answer
  carries `limit_note` saying what was asked and what was given. It is not an error. To see more, narrow the place or search a neighbouring one.
- `sort`: only `"price"`, and only with `check_in` and `check_out`. The hotels that have a live price come first, cheapest first (the order of
  "price: low to high" on our website), then the hotels with no price. The answer says how many had a price. Leave it out to keep our ranking.
- `currency`: see "The person's currency".

`query.resolved_to` tells you which place we matched: ambiguous names resolve
to the **largest** place of that name, so `"London"` gives you London, GB. Say
it back to the user if it might not be what they meant. `query.dates_read_as`
and `query.guests_read_as` say the dates (with the year, the weekdays and the
nights) and the guests as we read them ("Sunday, 14 March 2027 to Tuesday, 16
March 2027 (2 nights)", "2 adults"): confirm them with the person.

`source` is always `"catalog"`: the list comes from our own data, normally in well
under a second. Prices are asked of a booking partner only when the person's own dates are
given (below), never for a list without them. When we hold nothing for a place, `count` is `0`, `hotels` is empty,
and `search_url` is the site search for that place, with your dates and guests,
where a person gets a live search.

**A town we hold no hotels for.** When we know where a town is but hold no hotels
for it, we first look at the hotels we hold around it. If there are none, then
we may ask a booking partner once for the list of hotels
around that point (names and places only, never a price), keep what it finds and
answer from it. That ask waits a few seconds at most, happens once a day for a
place, and counts against the same limits as live price checks. `order` then
says these are the hotels in and around the place, by guest rating, and
`resolved_to` says which town we took it to be. If the list is still being
fetched when the wait runs out, `note` says to call `search_hotels` again in a
minute. A place we cannot find on our own map data stays an honest `0`.

**The order is our ranking, not rating order. Keep it.** For a place with a page
on local-hotel.com the set is that page's own, with `total_in_place` the number
we list for it. Where the place has a centre the order is the page's too: hotels
near the centre first, then by guest rating counting only hotels with enough
reviews to mean something. A place with no fixed centre on our site is listed
best rated first, with or without dates (its page opens lowest price first on
figures we do not give; pass `sort: "price"` for that order by live price). `order` says
which rule applied. A high rating from a handful of reviews is not a better
hotel, so weigh `rating` with `review_count`.

Each hotel is:

```jsonc
{
  "id": "cuid-xxx", "name": "...", "city": "...", "country": "GB", "stars": 5,
  "rating": 9.4, "review_count": 812,
  "property_type": "Hotels",                      // our booking partner's own word; null when it gives none
  "travel_to_centre": "7 min walk (0.5 km) from Liverpool centre",   // only when we have measured it
  "url": "https://local-hotel.com/hotel/hotel-cafe-royal-london-cuid#checkIn=2027-03-14&checkOut=2027-03-16&occupancy=2A"
}
```

**State a distance only from `travel_to_centre`.** It is a stored, measured
walking or car journey time (a car journey time ignores live traffic and says so).
When it is absent we have not measured that hotel, so do not estimate one. List
results carry no coordinates on purpose, so there is nothing to compute a straight
line from; `get_hotel_details` has them for maps.

**One kind of figure per column.** A measured hotel also carries `travel_to_centre_figures`, the same figures
as data and the same fields for every hotel: `leads_with` (which figure the line opens with), `walk_minutes`,
`distance_on_foot`, `distance_by_road`, `distance_by_road_and_ferry` and `straight_line`, each null where we do not
say that figure. A walk is given
in minutes only up to our walking limit; a longer one is given as its distance on foot, and a hotel measured only by
road as its distance by road. In a table give each kind its own column and leave a gap where a hotel has none; never
turn one kind into another.

**"Cheapest" only when it is true.** A priced list is the cheapest of the place only when it says
`price_order: "cheapest_first"` (then `price_comparison_covers` is `"whole_place"`). Every other priced list is
not, and says `price_comparison_covers: "listed_hotels"`. Its note begins with the rule that applies to it: a list in
our order, a list sorted only among the hotels listed, or a place too large to rank by price (narrow to an area for
that one). Of its prices say "the cheapest of these N", never "the cheapest in" the place. With `sort: "price"`, when the place-wide check
returned prices but ran into our booking partner's time limit, the list is the cheapest of the hotels that check priced:
`price_comparison_covers: "hotels_the_check_priced"` with `compared`, no `price_order`; say "the cheapest we found".
When hotels priced in
another currency were left out of a ranking, the field says `whole_place_priced_in_the_currency_asked` and
`not_compared_other_currency` counts them.

**Town prices.** A price is only ever a live one. When you call a list tool with the person's OWN
`check_in` and `check_out` (never dates you chose), every hotel in the list that our booking partner's answer
for the whole place includes carries a live price. We ask our booking partner ONCE for the whole place (the
same request our website's search makes) and at most once more for listed hotels that answer left
out (below), so a town costs one or two requests, not one per hotel, and the
price is the figure the website's search shows a visitor who is not signed in, for the same place,
dates, guests and currency. Each priced hotel has `price_type: "live"`, `price_for: "signed_out"`,
`live_price_per_night`, `currency`, `checked_at` and `price_source: "area_search"`. Where that figure includes
tax paid at the hotel, as the website's card shows it, the hotel also has `paid_at_hotel` (its
`amount` for the whole stay and `currency`) and a `price_note` in the website's words ("incl. £12.40 paid at
the hotel"). That amount is already in `live_price_per_night`, never on top of it: the hotel also has
`room_price_per_night` (the room alone), `stay_total` (everything, for the stay) and `nights`, and the
`price_note` says the same in words. It is a check, not
a promise: the hotel page asks again, its price is the one charged, and `get_hotel_rates` (one hotel's
own check) can differ. A hotel with no price has `price_type: "none"` and the reason
(`no_price_reason`), worded in `no_price_note`. When our booking partner's answer for the whole place held
hotels but not this one, we say what we can infer: `no_rooms` with `inferred: true` (the answer
listed the hotels it has rooms for and this one was not in it, so it very likely has none for this
stay; it is inferred from the place-wide answer, and `get_hotel_rates` checks the hotel alone), or
`not_checked` with `not_checked_because` saying why (for example `other_supplier`: its booking partner was not
covered by that request): call `get_hotel_rates` for those.

**The hotels we list are the hotels we price.** Our booking partner's answer for a whole place is cut at a
limit, and in a large place it holds the hotels our booking partner chose, not the ones we list. So a listed
hotel that answer did not cover (it was cut before reaching it, or the hotel lies outside the area asked
about) is asked for by id, with the others like it, in ONE further request; and for a place we know
is larger than one request covers we ask only for the listed hotels. Such a hotel has
`price_source: "listed_hotels"` and its own `checked_at`. Our booking partner's answer for several hotels at once
can leave hotels out, so a hotel missing from a request that named other hotels too has
`no_price_reason: "not_checked"` with `not_checked_because: "asked_with_others"`: its absence says nothing about
rooms, and `get_hotel_rates` checks it alone. Only a hotel asked for alone this way and missing from the answer has
`no_price_reason: "no_rooms"` with `inferred: true` and `inferred_from: "listed_hotels_request"` (inferred from
its absence, as a single check is). When that further request is refused by a limit or fails, or the list is longer than one request
holds, the hotel has no price and says why in its own `no_price_reason`; `get_hotel_rates` checks it alone. If every listed hotel is inferred to have no room,
the note says so in one sentence. A booking partner that answered without error but returned no hotel at all is an answer too: its hotels are `no_rooms`, inferred. A booking partner whose request failed, or whose outcome we cannot tell, gives `supplier_error` for its hotels, and nothing is inferred from a stale answer. The answer
counts them apart: `priced`, `no_rooms_inferred`, `not_checked` (and `unpriced` for all without a price). The list's order does not change for a price unless you ask.
The first ask for a place can take up to about twenty seconds (our booking partner's own limit for one request is twelve, and we wait a little beyond it), so the first call may be slow. A place whose answer left listed hotels out needs a second request for them; it runs beside the first when we can tell beforehand that it will be needed, and after it otherwise, and the two together are held to the same wait. If the check is still running when the wait ends, the list comes back unpriced with `no_price_reason: "still_checking"` (the check has not failed, and it carries on after the answer is sent): call the same tool again with the same arguments after a few seconds (it may have finished by then); `get_hotel_rates` prices one hotel meanwhile. If the check is refused or fails the list still comes back, unpriced. Whole-place
checks are limited, per connection and per day, separately from `get_hotel_rates`. With no dates a list
carries no price: ask for the dates first, then call the list tool again with them.

**The person's currency.** Ask the person which currency they want prices in, once, early, with
their dates and guests, and send the same `currency` (ISO 4217, for example `"GBP"`) on every call for
that person, the way you keep their dates and guests the same. Every tool that can return a price takes
it: `search_hotels`, `hotels_near_venue`, `hotels_near_place` and `get_hotel_rates`. We cannot know it
ourselves (the connector does not know who is asking, and the address a call comes from is your
service's, not the person's), so it is never guessed. The answer says what it read: `query.currency_read_as`
("British Pound (GBP)") and `query.currency_source`, `"asked"` or `"default"` (for `get_hotel_rates`
the same two fields sit at the top of the answer). A call without a currency still answers, in our default
currency (US dollars), and says so in `note`, telling you to ask the person and call again with it. A
currency we do not offer is answered in the default too, with `query.currency_rejected` naming the code
that was not offered. The hotel page may show prices in another currency than the one used here, and it checks the price
again. When you book, checkout shows the amount and the currency your card is charged in.

**Price order.** Call `search_hotels` with the person's dates and `sort: "price"`. What comes back
depends on what the price check covered, and the answer says which:

- **One price check covered the whole place.** The list is the cheapest hotels of the place, chosen
  from every hotel there that had a price (not only from the ones our ranking would have listed), in
  the same order as "price: low to high" on our website, compared in one currency. Only then does the
  answer carry `price_order: "cheapest_first"`, with `compared`: how many priced hotels were ranked.
- **The check did not cover the whole place** (it was cut short, a booking partner failed, or the list is
  not a town's). The listed hotels that have a price come first, in price order among themselves, and
  there is no `price_order`: `note` says how many of the listed hotels were compared and that a cheaper
  hotel may exist.
- **The place is too large** (below). Nothing is sorted.

When no hotel had a price, `no_price_reason` says exactly why.

**A price check cut short.** When our booking partner ran out of time before it finished answering for the
place, the answer carries `price_check_cut_short: true` and says so in `note`: some hotels may be
missing from it. It says nothing about the size of the place; call the same tool again.

**A place too large to rank by price.** A big city holds more hotels than one price check covers,
so we cannot say which is cheapest in one go and we do not pretend
to. Its dated answer keeps OUR order, prices the hotels it lists, never carries `price_order`, and
says so: `place_too_large_for_price_order: true`, one sentence in `note`, and, when we hold them,
areas of the place in `narrow_to`. An entry is given only for an area `search_hotels` can itself
find by name: it carries the area's `name`, its `country`, how many `hotels` we list there, and
`search_hotels`, the exact `location` and `country` to send. Do not call any hotel in such a list
the cheapest in the city. Ask the person which part of the city they want and call `search_hotels`
again with that entry's arguments, the same dates and `sort: "price"`; an area one price check covers
comes back in true price order, and one that is itself too large says so again. With no `narrow_to`,
narrow by what the person wants to be near: `hotels_near_place` or `hotels_near_venue`.

**One hotel, precisely.** Call `get_hotel_rates` for the hotel the person picks, or for up to 3
hotels from the list in `hotel_ids` (the same dates and guests), when they want that hotel's own
check.

**`member_price_url`.** A priced hotel, and a priced list, carries `member_price_url`: it opens
sign-in on our site and returns to the same stay with the same dates and guests. Signing in
can show a lower price, and a signed-in guest can save the search and set a price watch.
Offer it beside the price, exactly as returned.

**Every link is tagged.** Each link we return ends with `utm_source=mcp-<tool>` after the
`#` (for example `#checkIn=2027-03-01&checkOut=2027-03-03&utm_source=mcp-search_hotels`). It
is part of the fragment, which is never sent to our server and never indexed, so it makes no
second address and changes nothing about the page. Give links exactly as returned.

**`url` is where the human goes to book**, and it always carries the dates and the
guests (rooms, adults, child ages, the default two adults included), so they land
ready rather than re-entering or checking anything. Hand it over as-is.

### `get_hotel_details`

```jsonc
{ "hotel_id": "cuid-xxx" }   // the `id` from search_hotels
```

Returns `id`, `name`, `stars`, `rating`, `review_count`, `address`, `city`,
`country`, `phone`, `description`, `amenities[]`, `photos[]` (capped at 8),
`url`, `lat`, `lng`. Absent fields are `null` rather than omitted.

### `get_hotel_rates`

The LIVE price of one hotel for one stay, checked just now, and the page where the
person sees it and books. Use it for the hotel the person picks ("how much is
X for these dates?"), or once with up to 3 hotels from a list in `hotel_ids` when they ask about prices or deals,
never for a whole list: it asks our booking partner,
so it is limited per connection and per day, and a repeat of the same hotel,
dates and guests within a few minutes returns the same answer instead of asking
again. Past a limit you get no price and
the link (see below).

```jsonc
{
  "hotel_id":  "cuid-xxx",   // one hotel; or send "hotel_ids": ["cuid-a", "cuid-b", "cuid-c"] (up to 3) for the same stay
  "check_in":  "2026-09-01", // required YYYY-MM-DD
  "check_out": "2026-09-03", // required YYYY-MM-DD, at most 30 nights after check_in (the longest stay the site sells)
  "occupancy": "2A"          // optional, defaults to "2A"
}
```

A live price:

```jsonc
{
  "hotel_id": "cuid-xxx", "hotel_name": "...",
  "check_in": "2026-09-01", "check_out": "2026-09-03", "occupancy": "2A",
  "url": "https://local-hotel.com/hotel/hotel-cafe-royal-london-cuid#checkIn=2026-09-01&checkOut=2026-09-03",
  "price_type": "live",
  "live_price_per_night": 212, "currency": "GBP",
  "checked_at": "2026-11-12T10:00:00.000Z",
  "valid_until": "2026-11-12T10:15:00.000Z",
  "note": "Live price checked at ...: £212 a night, the lowest rate we were offered for these dates and guests, our fees included. It holds until ... at most. It is a check, not a promise: ..."
}
```

`live_price_per_night` is the lowest rate we were offered for those dates and
guests, per night, our fees included, in `currency`. Where that rate comes with an
amount paid at the hotel, as the website's search card and hotel page show it, the
figure includes it and the answer has the same fields as a priced list row:
`paid_at_hotel` (the amount for the whole stay), `room_price_per_night` (the room
alone), `stay_total` (everything, for the stay), `nights` and a `price_note` saying
the amount is already in `live_price_per_night`, not on top of it. `valid_until` is the latest
time it is good for: quote it with `checked_at`, and do not quote it after
`valid_until`. It is a check, not a promise: the hotel page fetches the price
again before anyone books, and the price shown there is the one charged.

For a stay a long way ahead the answer also carries `far_ahead_notice`: many hotels have
not released rooms that far ahead yet, so there are fewer hotels and prices than
nearer the time. Say so when you quote the price.

No price (a daily or per-connection limit was reached,
our booking partner did not answer, the hotel cannot be booked right now, or the dates
cannot be priced yet):

```jsonc
{
  "hotel_id": "cuid-xxx", "hotel_name": "...", "check_in": "...", "check_out": "...", "occupancy": "2A",
  "url": "https://local-hotel.com/hotel/...#checkIn=...",
  "price_type": "none",
  "no_price_reason": "no_rooms",
  "note": "We asked our booking partner just now and it answered: it has no room to sell at this hotel for these dates and guests. ..."
}
```

We never fill the gap with an older or typical figure, and we say exactly why there is none. `no_price_reason` is one of:

- Our booking partner was asked and answered: `no_rooms` (nothing to sell for this stay: the hotel may be full, or the dates
  may not be sold through our booking partner; we cannot tell which), `no_usable_rate`, `price_expired`.
- The check is still running: `still_checking` (ask again in a few seconds with the same arguments; it is not a failure).
- The check failed, which says nothing about rooms: `supplier_error` (our booking partner did not answer or returned an error),
  `pricing_error` (a fault on our side).
- No check was made: `daily_cap`, `client_limit`, `budget`, `cap_unavailable` (our limits), `supplier-off`,
  `not-bookable`, `no_live_check`, or the stay itself (`bad_dates`, `dates_out_of_range`, `stay_too_long`, `bad_party`).

Tell the person which it was. For `no_rooms` the hotel page asks the same booking partner, so suggest other dates or another
hotel; otherwise send them to `url`.

### `hotels_near_venue` and `hotels_near_place`

The hotels closest to a stadium, arena, theatre or other venue (`hotels_near_venue`),
or to a landmark, attraction, station or airport we hold (`hotels_near_place`),
with each hotel's walking or car journey time to it. The times are measured along real
roads and paths and stored: they are not live, a car journey time ignores live traffic and
says so ("longer at busy times"), and a hotel we have not measured has no time at
all (we never guess one). The list is in the order of the figure shown. With the person's
own `check_in` and `check_out` every hotel in the list that our booking partner's answer for the whole
place includes also carries its live price and a `member_price_url`, as in `search_hotels` ("Town prices");
without dates there are no prices: ask for the dates, or call `get_hotel_rates` for the hotel the
person picks, or for up to 3 hotels from the list.

```jsonc
{
  "venue": "wembley-stadium",   // hotels_near_venue: the slug from search_events (`nearby_hotels.venue`) or a name
  // "place": "eiffel-tower",   // hotels_near_place: a slug or a name instead
  "max_walk_minutes": 15,       // optional 1-120: only hotels whose stored walk is that or less
  "check_in": "2026-09-01", "check_out": "2026-09-03", "occupancy": "2A",   // optional: only put the stay on each hotel `url`
  "limit": 10                   // optional, defaults to 10; at most 20 (more than 20 gives 20 and `limit_note` says so)
}
```

Returns `{ venue | place, source: "stored_travel_times", count, ordered_by, hotels[], note }`. `ordered_by` says in
one sentence what the order follows. With dates, a priced list says `price_comparison_covers: "listed_hotels"`: these
are the nearest hotels, not in price order, so say "the cheapest of the N nearest", never "the cheapest near" the place.

`max_walk_minutes` (1 to 120) keeps only hotels whose line states a walk of that many minutes or fewer (`walk_minutes`). A hotel with no walk in minutes is left out
when it is set, because we never guess one. `limit` works as in `search_hotels`: at most 20, and `limit_note` says so when you ask for more.
Each hotel is:

```jsonc
{
  "id": "cuid-xxx", "name": "...", "stars": 4, "rating": 8.7, "review_count": 412,
  "travel": "9 min walk (0.7 km) from Wembley Stadium",   // or null when not measured
  "walk_minutes": 9,                                      // the walk in minutes the line states, or null (the same value as in travel_figures)
  "travel_figures": {                                     // the line's own figures as data; null when not measured
    "leads_with": "walk_minutes",                         // or walk_distance (a walk worded as a distance), distance_on_foot (a walk too long to
                                                          // call one), distance_by_road, distance_by_road_and_ferry, straight_line, terminals
    "walk_minutes": 9, "distance_on_foot": "0.7 km", "distance_by_road": null, "distance_by_road_and_ferry": null, "straight_line": null
  },
  "url": "https://local-hotel.com/hotel/...-cuid#checkIn=2026-09-01&checkOut=2026-09-03"
}
```

A venue name that fits more than one venue ("Allianz Stadium" is Sydney's, Turin's and,
as a sponsor name, Twickenham's) picks the best match and lists every other in
`also_matches` (each with its `slug`, city and country); `understood.picked` says which was
taken. The rule: the person's `country` (optional, ISO2 or a name) or the city in the name
("Allianz Stadium, London"); then the venue with more upcoming events (one with none never
leads one that has some); then a venue whose own name it is over a nickname; then slug. Pass
the country or city whenever you know where the person means. A venue is also found by a name
it is known by ("Twickers") or a part of its name ("Twickenham"). A name that matches several landmarks returns `candidates[]`: call again
with the slug. A name we do not hold returns `count: 0` and `search_url`,
the site search for it. A merged duplicate venue name answers as its main venue.

### `search_events`

Upcoming curated events — sport, music, festivals, air shows — with the
event's own "where to stay" guidance. Use it to answer "where should I stay
for the Boston Marathon?" in one call.

```jsonc
{
  "query": "Boston Marathon", // optional — event name or fragment
  "city":  "Boston",          // optional — venue city
  "country": "US",            // optional — the city's country (ISO2 or name);
                              // "Newcastle" + "AU" is New South Wales, not Tyneside
  "from": "2027-04-01",       // optional — only events on or after this day (the venue's own calendar day)
  "to":   "2027-04-30",       // optional — only events on or before this day
  "limit": 10                 // optional, 1-20
}
```

With a `query`, each music result carries `billing`: `"artist"` when the event's stored
performer is the query, or the query starts with it and only a four-digit year, a filler word
("tickets", "tour", "live", "concert", "show", "gig", "tonight", "in", "at", "uk", "usa") or that
show's own city, country or venue follows ("St Paul & the Broken Bones Brisbane" for the Brisbane
show), with case, accents, punctuation, "&" and "and", Saint and St, and a leading "the" set
aside. The act in the middle or at the end of the query ("Luke Combs Texas") is not the artist,
and tribute wording keeps a longer query "other". Otherwise `"other"` (a tribute act,
or any other listing that has the name in it, or a performer that merely contains the
query, like "Queen Nation" for "Queen"). The artist's own
shows come first, soonest first, then the others, soonest first. Where no performer is
stored, the name must be the query or start with it and a separator (" - ", ":", " w/ ",
" with ", " tour") with no tribute wording. If no listing is the artist's own, `no_shows_by`
says so and `best_match` says it is not a show by the artist: never present a tribute act as
the artist. Sport and other non-music listings carry no `billing`, and a query that is
not an artist (a venue, city, team) gets no such note. (Before, every event was simply
soonest first.) Returns `{ understood, assumed, count, events[], best_match? }`.
`understood` and `assumed` echo the search as we read it, and the assumed guests (two
adults, one room): say them back to the person. With a `query`, `best_match` holds the
first match's nearest hotels with their stored walking and car journey times, no prices (we pick, we do not ask; the other
matches follow in `events`). Each event is:

```jsonc
{
  "name": "Boston Marathon 2027",
  "url": "https://local-hotel.com/events/boston-marathon-2027",
  "start_date": "2027-04-18", "end_date": null,
  "local_start": "Sunday, 10am", "local_start_time": "10am",      // the venue's own clock
  "suggested_stay": { "check_in": "2027-04-17", "check_out": "2027-04-19", "nights": 2, "label": "Sat 17 – Mon 19 Apr (2 nights)" },  // the dates its event page opens the hotel search on; null for a long run
  "segment": "Athletics",
  "venue": { "slug": "boylston-street", "name": "Boylston Street", "city": "Boston" },
  "nearby_hotels": { "tool": "hotels_near_venue", "venue": "boylston-street" },
  "where_to_stay": [
    { "area": "Back Bay, Boston", "note": "Best: Back Bay — the finish, the expo…" }
  ]
}
```

Each `where_to_stay[].area` string is designed to be passed straight to
`search_hotels` as `location` — that pairing answers the whole question:
event dates from here, hotels from there. `nearby_hotels` is the next step
for "which hotels are closest to it?": pass its `venue` to `hotels_near_venue`. Events are curated
editorial data; `count: 0` means we don't cover that event, not that it
doesn't exist.

## A visual list (MCP Apps)

`search_hotels`, `hotels_near_place` and `hotels_near_venue` also return a compact visual list of their hotels for apps
that support MCP Apps. Each of the three tools names it in `_meta.ui.resourceUri`: `ui://local-hotel/hotel-list-v2.html`
(mime type `text/html;profile=mcp-app`). The list is drawn from the result's `_meta` (key `local-hotel.com/hotel-list`, kept out of the model's context): the same hotels, links
and live prices as the JSON text, with a stay's price given as its total for the nights. It loads only hotel photos and
fetches nothing. The JSON text block is unchanged, so an app without MCP Apps sees exactly what it saw before.

## Prompts

Seven ready-made starting points an app can list beside the tools (`prompts/list`, then `prompts/get`). Each one
turns what the person typed into an ask in plain words that names the tools to use; it fetches nothing until the
assistant calls a tool. Every argument is plain text. The prompt never invents what was left out: with no guests it asks
who is coming (adults, and the ages of any children), and with no dates it asks for them, except `weekend_away`, which
uses the coming Friday to Sunday and says which dates it used. Any price comes from `get_hotel_rates`, with the time it
was checked.

### `stay_for_event`

Where to stay for an event. Arguments: `event` (required, e.g. "British Grand Prix"), `city`, `guests`. Leads to
`search_events` for the event's dates and venue, then `hotels_near_venue`.

### `near_a_venue`

Hotels near a venue. Arguments: `venue` (required, e.g. "Wembley Stadium"), `dates`, `guests`. Leads to
`hotels_near_venue`, nearest first.

### `near_a_place`

Hotels near a landmark, attraction, station or airport. Arguments: `place` (required, e.g. "Brighton Pier"), `dates`,
`guests`. Leads to `hotels_near_place`, nearest first.

### `weekend_away`

A weekend away in a town or city. Arguments: `place` (required, e.g. "York"), `dates`, `guests`. Leads to
`search_hotels`, in our order.

### `compare_hotels`

Compare hotels for my dates. Arguments: `hotels` (required, the hotel names or ids, up to 3), `dates`, `guests`. Finds
each with `get_hotel_details` (or `search_hotels` for a name), then one `get_hotel_rates` call with every hotel in
`hotel_ids`, and sets price, rating and travel figures side by side.

### `family_stay`

A family stay. Arguments: `place` (required, e.g. "York"), `dates`, `guests`. If children are coming and their ages are not given, asks for each child's age first,
as the ages decide which rooms can be offered, then `search_hotels` with `adults` and `child_ages`.

### `early_start`

An early start from a station or airport. Arguments: `place` (required, the station or airport), `dates`, `guests`.
Leads to `hotels_near_place`, nearest first by walking time, and says which hotels are an easy walk.

## Errors

A failed tool call returns `isError: true` with a JSON body carrying `error`
and the id you passed. Treat a single failure as transient and tell the user
rather than hammering the endpoint.

## What the prices mean

The live price on the hotel page is the total we charge for the stay, with the service
charge already included. No booking fees from us. The `live_price_per_night` figure
`get_hotel_rates` returns is a per-night check of the lowest rate for your dates,
including our service charge; the page re-checks it and its price is the one charged.
The answer says whose price it is (`price_for: "signed_out"`): A price from the connector is the one a visitor who is not signed in sees. Visit local-hotel.com direct and sign in, and we can often improve on the price shown here.
A few places charge a local
tourist tax, paid at the hotel (Edinburgh's 5% visitor levy, for example): it
is paid at the hotel, not to us (Terms, section 4). Where the supplier states such an
amount and the website shows it, the answer includes it and says so (`paid_at_hotel`,
`price_note`); where an answer has no `paid_at_hotel`, say that a local tourist tax
may be due at the hotel when you quote a stay.

The price does not change depending on how the user arrived, and there is no
invented scarcity. If you are comparing us against other sources, compare the
total we charge: that is the number on the hotel page, and this API points you to it.

## Limits and etiquette

- No stored or typical prices exist in this API. There is no bulk feed, dump or
  export, and the list tools cap at 20 results per call. Paginating through the
  whole catalogue is not a supported use.
- Cache a `get_hotel_details` response freely: it is fairly static. Do not cache a
  `get_hotel_rates` price beyond its `valid_until`.
- Live prices are limited: a few per connection per hour and a daily total across
  everyone. Past a limit you get the no-price answer and the `url`; do not retry in
  a loop. Requests are also rate-limited per IP: back off on a 429.
- Booking tools (prebook, confirm, cancel) are not exposed. Booking happens on
  the `url` each tool returns.

Questions or a problem with the API: https://local-hotel.com/press
