Skip to main content
Glama
gabrielnika

webhotelier-mcp

by gabrielnika
README.md
# 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

A3.7/5.0

Scored across 8 tools

Disambiguation4/5

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.

Naming Consistency5/5

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.

Tool Count5/5

8 tools is well within the ideal range. Each tool serves a clear purpose in the hotel query domain without redundancy.

Completeness4/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues