webhotelier-mcp
# webhotelier-mcp
A **read-only [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server** for the
[WebHotelier](https://www.webhotelier.net) REST API.
It lets an AI assistant (Claude Code, Claude Desktop, or any MCP-compatible client) answer
questions like these with **live hotel data**:
> *"Is there a double room at GOLDENSAND for Sep 3–5 for 2 adults, and at what price?"*
> *"Which days in August still have availability?"*
> *"What's the cheapest rate this weekend, and what's the cancellation policy?"*
The assistant picks the right tool, the server calls WebHotelier, and the answer comes back
grounded in real availability and real prices — not guesses.
---
## Table of contents
- [How it works](#how-it-works)
- [The tools](#the-tools)
- [Quickstart](#quickstart)
- [Configuration](#configuration)
- [Connecting a client](#connecting-a-client)
- [Architecture](#architecture)
- [Design decisions](#design-decisions)
- [Development & testing](#development--testing)
- [Troubleshooting](#troubleshooting)
---
## How it works
MCP is an open protocol that gives language models a standard way to call external systems.
The flow for every question:
```
┌────────────┐ JSON-RPC over stdio ┌─────────────┐ HTTPS ┌──────────────────────────┐
│ MCP client │ ──────────────────────▶ │ server.js │ ───────────────▶ │ rest.reserve-online.net │
│ (Claude) │ ◀────────────────────── │ (this repo)│ ◀─────────────── │ (WebHotelier REST API) │
└────────────┘ tool results └─────────────┘ JSON payloads └──────────────────────────┘
```
1. On startup, the client launches `node server.js` as a subprocess and performs the MCP
handshake over stdin/stdout.
2. The server advertises its **8 tools**, each with a name, a natural-language description,
and a JSON Schema for its parameters. The model reads these to decide when and how to
call each tool.
3. When the model calls a tool, the server validates the arguments (zod), calls the
WebHotelier endpoint with HTTP Basic Auth, **slims the response** (see
[Design decisions](#design-decisions)), and returns JSON text that lands in the model's
context.
4. Errors come back as *readable results*, not crashes — the model sees a message that
tells it what to do next (e.g. *"Unknown property code — call list_properties for valid
codes."*).
## The tools
All eight tools are **read-only**. The server implements no write endpoint of any kind.
| Tool | What it answers | Required params | Optional params |
|------|-----------------|-----------------|-----------------|
| `list_properties` | Which hotels exist and their property codes. Local registry lookup — no API call. | — | — |
| `get_property_info` | Hotel profile + full room catalog (room types, capacities, amenities). | `property` | — |
| `get_availability` | Is there a room for these dates/party, and at what price. The workhorse. | `property`, `checkin` | `checkout` *or* `nights`, `adults` (default 2), `children`, `infants`, `rooms` |
| `get_rates` | Rate plans and cancellation policies. | `property` | `room` |
| `get_calendar` | Day-by-day availability over a date range. | `property`, `from`, `to` | `adults`, `children` |
| `get_best_rate` | Cheapest available rate (BAR — Best Available Rate). | `property` | `date`, `adults`, `children` |
| `get_offers` | Active special offers / packages. | `property` | — |
| `get_reservations` | Booking search by property and check-in date range.* | — | `property`, `from`, `to` |
All dates use `YYYY-MM-DD`. `property` is the WebHotelier property code (e.g. `GOLDENSAND`);
the model is instructed to call `list_properties` first when it doesn't know a code.
\* `get_reservations` requires a WebHotelier account with reservations privileges. Without
them the API returns `403 NO_PRIVILEGES`, and the tool degrades to a clear message —
*"The configured WebHotelier account does not have reservations access; all other tools
work normally."* If your credentials are later upgraded, the tool starts working with zero
code changes.
## Quickstart
Requires **Node.js ≥ 20**.
```bash
git clone <this repo>
cd webhotelier-mcp
npm install
cp .env.example .env # then fill in WH_USERNAME / WH_PASSWORD
npm run smoke # optional: verify your credentials against the live API
```
`npm run smoke` should end with `SMOKE PASS`.
## Configuration
All configuration lives in `.env` (gitignored — credentials never enter the repo):
| Variable | Required | Purpose |
|----------|----------|---------|
| `WH_USERNAME` | yes | WebHotelier API username (HTTP Basic Auth) |
| `WH_PASSWORD` | yes | WebHotelier API password/key |
| `HOTEL_REGISTRY_PATH` | no | Absolute path to a JSON file backing `list_properties` (see below) |
### The hotel registry
`list_properties` reads a local JSON file so the model can *discover* valid property codes
instead of guessing them. Shape:
```json
{
"hotels": {
"my-hotel": {
"id": "my-hotel",
"name": "My Hotel",
"webHotelierCode": "MYHOTEL",
"rating": 4,
"active": true
}
}
}
```
Only these five fields are ever exposed — anything else in the file is filtered out (and a
unit test enforces that). Without a registry, `list_properties` explains it is not
configured; every other tool still works if you already know your property codes.
## Connecting a client
### Claude Code
Add to your project's `.mcp.json` (or to `~/.claude.json` for user-wide scope):
```json
{
"mcpServers": {
"webhotelier": {
"command": "node",
"args": ["/absolute/path/to/webhotelier-mcp/server.js"]
}
}
}
```
Restart the session (MCP servers are launched at startup) and check with `/mcp` — you
should see `webhotelier` with 8 tools.
### Claude Desktop
Add the same entry under `mcpServers` in `claude_desktop_config.json`
(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`), then restart
the app.
### Any other MCP client
Anything that speaks MCP over stdio can use this server — point it at `node server.js`
with the repo as working directory or use absolute paths as above.
## Architecture
```
webhotelier-mcp/
├── server.js # entry point: McpServer + stdio transport
├── tools.js # the 8 tool definitions (zod schema + thin handler each)
├── format.js # response slimming before data reaches model context
├── registry.js # hotel-registry loader with strict field whitelist
├── errors.js # WebHotelier errors → actionable text for the model
├── env.js # dotenv loading (must stay the FIRST import of server.js)
├── lib/
│ └── wh-client.cjs # vendored WebHotelier REST client (CommonJS)
└── tests/
├── unit/ # offline unit tests (node:test, no framework deps)
├── fixtures/ # fake registry used by the privacy tests
└── smoke.js # live-API smoke test
```
Everything testable without a network — formatting, registry filtering, error mapping —
is a pure module with unit tests. `tools.js` stays declarative: schema in, client call,
slimmed JSON out.
`lib/wh-client.cjs` is a vendored, battle-tested HTTP client: Basic Auth, request
timeouts, and a retry policy for transient failures (408/429/5xx and common network
errors; backoff 500 ms → 1.5 s → 4.5 s, honoring `Retry-After` on 429). Permanent errors
(400/401/403/404) are never retried.
## Design decisions
**Read-only by construction.** The safety guarantee is structural, not a permission flag:
no create/modify/cancel endpoint exists anywhere in the codebase, so no prompt or bug can
reach one.
**Errors are results, not crashes.** A tool failure returns `isError: true` with text
written *for the model*: what happened and what to do next. The server process never dies
mid-session because one API call failed.
**Responses are slimmed for context windows.** WebHotelier payloads carry bulk that a
language model doesn't need: a single property-info response can exceed 100 KB, largely
photo URLs and HTML descriptions. `format.js` replaces photo arrays with `photo_count`
and strips/truncates HTML descriptions — while passing every number (prices, allotments,
capacities) through untouched. The model should never quote an altered price.
**Registry privacy is tested, not promised.** The registry loader whitelists five fields;
a unit test feeds it a fixture full of fake sensitive data (emails, credential paths) and
asserts none of it survives into the output.
**stdout is sacred.** stdio-transport MCP servers speak JSON-RPC on stdout. A single
stray `console.log` corrupts the protocol stream — all logging here goes to `console.error`
(stderr), which clients surface as server logs.
**Credential loading is order-sensitive.** The vendored client computes its Basic-Auth
header at module load, so `import "./env.js"` must remain the first import in
`server.js` — ESM executes imports in declaration order.
## Development & testing
```bash
npm test # offline unit tests (node:test — zero test-framework dependencies)
npm run smoke # live smoke test: registry, property info, availability, 403 handling
npm run inspect # MCP Inspector web UI — call tools manually, watch raw JSON-RPC
```
The MCP Inspector also has a CLI mode, useful for scripted checks:
```bash
npx @modelcontextprotocol/inspector --cli node server.js --method tools/list
npx @modelcontextprotocol/inspector --cli node server.js --method tools/call --tool-name list_properties
```
## Troubleshooting
| Symptom | Cause & fix |
|---------|-------------|
| Tools return *"credentials rejected (401)"* | `WH_USERNAME`/`WH_PASSWORD` missing or wrong in `.env`. Run `npm run smoke` to verify. |
| `get_reservations` returns a permission message | Your WebHotelier account lacks reservations privileges (`403 NO_PRIVILEGES`). Expected for API-only accounts; every other tool is unaffected. |
| `list_properties` says no registry configured | Set `HOTEL_REGISTRY_PATH` in `.env` to a registry JSON (shape above), or skip it and use property codes directly. |
| Server doesn't appear in the client | MCP servers launch at client startup — restart the session/app after editing the config. Check the path in `args` is absolute and correct. |
| *"Unknown property code (404)"* | The property code doesn't exist on WebHotelier. Call `list_properties`, or double-check the code. |
| Contributing a change and output looks corrupted | You logged to stdout. Use `console.error` — stdout belongs to the JSON-RPC stream. |
TDQS
Scored across 8 tools
Most tools target distinct data types: rates, property info, availability, calendar, best rate, offers, and reservations. get_availability and get_calendar overlap slightly, but descriptions clarify one is for a specific stay and the other for a date range. get_best_rate is a distinct query for the cheapest rate.
All tools use a verb_noun pattern, predominantly 'get_'. The single exception is 'list_properties', but this is still a clear verb and similar in style. The naming is predictable and consistent.
8 tools is well within the ideal range. Each tool serves a clear purpose in the hotel query domain without redundancy.
The toolset covers the core read-side operations: property lookups, rates, availability, calendar, best rate, offers, and reservations. It lacks write operations, but the server appears intentionally read-only. Minor gap: no tool to fetch a single reservation by ID, but search can accomplish this.