Pitchup.com MCP server
# Pitchup.com MCP server
An [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT and other MCP clients work with a campsite's Pitchup.com supplier account: campsites, pitch types, pitches, charge types, prices and stay rules, allocation, extras and bookings, and (when enabled) setting allocation, prices and charge types. It is built from Pitchup's public [API Integration Guide](https://docs.pitchup.com/api/), which is published as one OpenAPI 3.0.3 document (`docs.pitchup.com/api/pitchup-api-openapi.yaml`, 24 operations, 14 schemas, with the whole guide in its description).
Once it's connected, a site owner or manager can ask things like:
- "Who's arriving on Saturday, how many people and dogs, and does anyone have special requests?"
- "What bookings came in or changed since yesterday morning?"
- "What's the nightly price for electric pitches in August, and which days are closed to arrivals?"
- "Is there allocation for the bell tent from 14 to 18 July?"
- With writes enabled: "Set max allocation to 3 for electric pitches for all of August, and put weekend prices up to £28."
## Tools
| Tool | What it does | API calls |
|---|---|---|
| `api_root` | The resources the key can reach, plus the environment and API version the server is using. A quick key check. | `GET /rest/api/` |
| `list_campsites` | Campsites: slug, name, state, currency, categories, pitch type IDs, availability. | `GET /rest/api/campsite/` |
| `get_campsite` | One campsite: languages, child and infant ages, arrival and departure times, opening dates, rating, notices and policies. | `GET /rest/api/campsite/{slug}/` |
| `list_pitch_types` | Pitch types with capacity, persons included, pricing method, pitch count, lead price, facilities, charge type and pitch IDs. Optional campsite filter, applied by this server. | `GET /rest/api/pitchtype/` (documented in the guide's "Pitch type" section, not in the spec's `paths`) |
| `get_pitch_type` | One pitch type. An answer that does not carry the requested ID is treated as not found. | `GET /rest/api/pitchtype/{pk}/` |
| `list_pitches` | Pitches (units): pitch type, name, bookable by Pitchup, priority, your external ID, calendar sync status. Filters: `external_id` (API), pitch type (this server). | `GET /rest/api/pitch/` |
| `list_charge_types` | Charge types (tariffs) with pitch type, active flag and status. Optional pitch type filter, applied by this server. | `GET /rest/api/chargetype/` |
| `get_charge_type` | One charge type. | `GET /rest/api/chargetype/{chargetype_id}/` |
| `get_pricing` | Prices and stay rules from arrival days: pitch, adult, child and infant prices, pricing period, minimum and maximum stay, closed to arrival or departure, status, pitches sold and left. Filters: `date`, `after`, `before` (API); charge type or pitch type (this server). Read-only: Pitchup's `POST .../pricing/` endpoint sets prices and is used only by `set_pricing`. | `GET /rest/api/arrival/`, and `GET /rest/api/pitchtype/{pk}/` for the pitch type filter |
| `list_bookings` | Bookings with dates, status, lead guest name, party size, pitch, unit, extras, special requests and amounts. Every documented filter: `after`, `before`, `modified_after`, `modified_before`, `arrive`, `depart` and their `__gt`, `__gte`, `__lt`, `__lte` forms, `status` (sent as its documented numeric key), `first_name`, `last_name`, `campsite`, `pitch`, `external_id`. | `GET /rest/api/booking/` |
| `list_arrivals` | Who arrives on one date: confirmed and reserved bookings by default (others are counted; the status is compared case-insensitively), sorted by pitch type and name, with party totals. The dog total only counts bookings that carry a dog count, and is `null` with a note when none does (see Status). | `GET /rest/api/booking/?arrive=…` |
| `list_allocations` | Allocation days: max allocation and pitches left to sell per date and pitch type. Filters: `date`, `after`, `before` (sent to the API; named in the guide's general filtering section, not in its allocation section, so unconfirmed for allocation days); pitch type (this server). | `GET /rest/api/allocation/` |
| `check_availability` | For one pitch type and a stay, the allocation of every night (nights without an allocation day and nights with nothing left are named) and the arrival days on the arrival date for that pitch type's charge types. It does not reproduce Pitchup's pitch assignment or price calculation. | `GET /rest/api/pitchtype/{pk}/`, `/allocation/`, `/arrival/` |
| `list_extras` | Extras with price, pricing type and period, maximum quantity, compulsory flag and charge types; optionally the dated prices of variable-priced extras. | `GET /rest/api/extra/`, `/extraprice/` |
| `set_allocation` | Sets `max_allocation` for one pitch type on up to 90 dates (the documented limit), overwriting existing allocation days. Reads the pitch type first and uses its own `url` in the body; nothing is sent unless that record carries the requested ID and its `url` ends in `/pitchtype/{pk}/`. Writes only. | `GET /rest/api/pitchtype/{pk}/`, `POST /rest/api/allocation/` |
| `set_pitch_type_allocation` | Sets either `max_allocation` or `allocation` for one pitch type from `start` to `end`, as a Pitchup background job; returns its `task_id`. Writes only. | `POST /rest/api/pitchtype/{pk}/allocation/` |
| `set_pricing` | Updates prices and stay rules of one charge type from `start` to `end`, optionally on some weekdays only; only the fields given change, as documented. Background job; returns its `task_id`. Writes only. | `POST /rest/api/chargetype/{chargetype_id}/pricing/` |
| `update_charge_type` | Renames a charge type, changes its internal description or switches its pricing on or off. Reads the charge type first and sends it back whole, because the documented update is a `PUT`. Writes only. | `GET` and `PUT /rest/api/chargetype/{chargetype_id}/` |
Not covered on purpose: every `DELETE` (charge types, bookings, pitches, webhooks), creating or amending bookings and Reserved bookings, creating pitches and charge types, `POST /rest/api/arrival/` (single arrival days; `set_pricing` covers ranges), `PATCH /rest/api/extra/`, webhooks, and the currency and language lists.
## Setup
Requires Node 18 or later.
```bash
npm install
npm run build
```
You need an API key: in the Pitchup Manager Portal, open **My details** and copy the key from the API key section. Sandbox and Live keys are different, and the sandbox needs its own sign-up at [www.sandbox.pitchup.com/supplier2/signup/](https://www.sandbox.pitchup.com/supplier2/signup/). The key is sent as `Authorization: Token <key>`; a key pasted with its `Token ` prefix is accepted and the prefix dropped.
**Claude Desktop:** add this to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"pitchup": {
"command": "node",
"args": ["/absolute/path/to/pitchup-mcp/dist/index.js"],
"env": { "PITCHUP_API_KEY": "your-sandbox-key", "PITCHUP_ENV": "sandbox" }
}
}
}
```
**Claude Code:**
```bash
claude mcp add pitchup -e PITCHUP_API_KEY=your-sandbox-key -e PITCHUP_ENV=sandbox -- node /absolute/path/to/pitchup-mcp/dist/index.js
```
| Variable | Required | Meaning |
|---|---|---|
| `PITCHUP_API_KEY` | yes | Your API key from My details, sent as `Authorization: Token <key>`. |
| `PITCHUP_ENV` | no | `sandbox` (default, `https://www.sandbox.pitchup.com`) or `live` (`https://www.pitchup.com`), the two servers in the spec. Anything else stops the server at start-up. |
| `PITCHUP_API_VERSION` | no | The API version sent in the `Accept` header, as the guide recommends. Defaults to `2023-08-25`, the newest dated version the guide lists; `prerelease` or another dated version can be set. |
| `PITCHUP_ALLOW_WRITES` | no | `true` to register `set_allocation`, `set_pitch_type_allocation`, `set_pricing` and `update_charge_type`. Off by default. |
| `PITCHUP_BASE_URL` | no | Overrides the host (the server adds `/rest/api/`). Used by the tests. Must not contain a username or password. |
## Safety defaults
- Read-only unless `PITCHUP_ALLOW_WRITES=true`. Read tools carry the MCP `readOnlyHint` annotation. The four write tools are marked `destructiveHint: true` (each overwrites existing allocation days, prices or charge type fields; setting allocation to 0 stops sales on those dates) and `idempotentHint: true`. Nothing is ever deleted: the server has no tool that sends `DELETE` or `PATCH`, and the test suite asserts that no such request is made.
- Sandbox by default. `PITCHUP_ENV=live` has to be set on purpose.
- Guests: the lead guest's name, party size, dates, pitch, unit, extras, special requests and amounts are returned by default. The booking's structured contact fields (email, telephone, postal address with street, city, county, postcode and country, `names_of_all_party_members`, `car_registration_number` and `child_ages`) are only returned when a tool is called with `include_contact_details=true`.
- Free text is not withheld, only redacted, and the redaction is a heuristic. A campsite can require guests to write their vehicle registration and the names of everyone in the party in the special requests field (the pitch type's `require_car_registration` and `require_party_names`, which the spec describes as "included in the special requests field"), and guests can type anything else there. By default, in special requests, the unit description, group name and cancellation reason: email addresses become `[email redacted]`; phone-number-like sequences become `[phone redacted]` (international numbers written with `+` or `00`, including `+44 (0)7700 …`, UK numbers with a bracketed area code, UK-style numbers starting with `0`, and 10-digit numbers starting with `7`, a UK mobile without its `0`; other such digit strings may be redacted too, while IDs, dates and amounts are left alone); upper-case UK postcodes become `[postcode redacted]`; and current-format UK registrations (`AB12 CDE`) become `[registration redacted]`. **Names (such as the party names a campsite asks for), street addresses, lower-case postcodes, older or foreign registration formats and dates of birth written in free text are returned as typed.** The test suite checks this with a booking on a pitch type that requires both.
- Payment data is never returned, even on request: the card type and number, `charge_id`, `token_id`, `stripe_charge_id` and the `data` field of a booking, and the campsite's `payment_email`, `stripe_token`, `card_types` and payment timings. Only the payment status word (`pending`, `paid` …) and its due date are passed on. A card number a guest types into free text (13 to 19 digits starting with 1 to 9, grouped or not) is replaced with `[card number redacted]` in every case, with or without `include_contact_details`; this is a pattern match, so a card number written in some other way (split by words, say) would not be caught.
- The campsite's own email, the manager's email, its phone numbers and postal address are only returned with `include_contact_details`; its notices, useful info and cancellation policy have emails, phone numbers, UK postcodes and UK registrations redacted by default, as above.
- Pitch calendar links are only returned with `include_contact_details`: the guide describes the Pitchup feed (`pitchup_calendar_feed`) as a signed link whose events carry the customer's name, telephone, email and address, and external feed links often carry their own tokens. By default a pitch shows how many external feeds it has and their sync status. Nothing is ever downloaded.
- Input is checked before any call: pitch type, pitch and charge type IDs must be positive whole numbers (the spec types them as numbers), campsite slugs letters, digits, `_` and `-`, dates real `YYYY-MM-DD` dates, booking creation and modification filters a date or `YYYY-MM-DD HH:MM[:SS]` (the guide's datetime example), prices decimal strings such as `10.50`. Write tools refuse locally a range that ends before it starts, a repeated date, `min_days` above `max_days`, both or neither of `allocation` and `max_allocation`, and a pricing request with nothing to change.
- Pagination follows the `next` link exactly as Pitchup returns it, as the guide asks, with `page_size=100` (the documented maximum) on the first request, until `next` is null or the tool's `max_results` is reached; an empty page with a `next` link is followed too. A list cut short says so with `complete: false` and a note, whether it was cut by Pitchup's paging limit or by `max_results` after a filter this server applies itself (pitch type, charge type, campsite). A `200` whose JSON is not a list (no `results` array) is an error, not an empty list. A `next` link on another host is not followed, so the API key is only ever sent to the configured Pitchup host.
- `check_availability` reads at most 30 pages of allocation days (7.5 seconds of request spacing plus Pitchup's own response time), to stay within the MCP client's default 60-second request timeout in case Pitchup ignores `after`/`before` on allocation days; when it stops early it says so and does not claim that a night it did not find has no allocation day.
- Rate limits: the guide documents none; its best-practice list only says failed requests should be retried "on a reasonable schedule". Requests are spaced 250 ms apart (about four per second, a guess on the polite side). A 429 is retried at most twice for any method, on the assumption that a rate-limited request was not processed, waiting for `Retry-After` (whole or fractional seconds, or an HTTP-date; 2 s before the second attempt and 4 s before the third when the header is absent). Each wait is capped at 10 seconds; if Pitchup asks for longer, the call gives up at once and says how long to wait.
- 502, 503 and 504 are retried the same way for `GET` only; after three failures the error says the service may be unavailable, without the gateway's HTML. A write is never retried after a gateway error, because Pitchup may already have queued the job; the error says to check the current values with the matching list tool first.
- A 200 whose body is not JSON (a proxy or login page) is an error described by content type and size, never an empty list. A rejected key produces a message naming `PITCHUP_API_KEY`, the environment in use and the Sandbox/Live difference. Error messages pass on only JSON fields from Pitchup (at most six), with contact details redacted and the API key scrubbed.
## Tests
```bash
npm test
```
The test suite (30 checks, about 195 requests to the mock, about 60 seconds):
1. Validates every fixture record against the schemas in Pitchup's published OpenAPI document with Ajv (`strict: false`, `ajv-formats`, plus the spec's `decimal` format): `CampsiteBase`; the `GET /pitchtype/{pk}/` results item (with all 44 keys it marks required) and `PitchTypeBase`; the `POST /pitch/` response and `PitchBase`; `ChargeTypeBase` and the `GET /chargetype/{chargetype_id}/` response; the `GET /arrival/` results item and `ArrivalBase`; `AllocationBase`; `ExtraBase`; `ExtraPriceBase`; the `POST /booking/` response and `BookingBase`; and the API root. The spec is downloaded from `docs.pitchup.com/api/pitchup-api-openapi.yaml` to `spec.yaml` on the first run. Where a component schema disagrees with the guide's own examples, the disagreeing properties are left out of that one check and the suite proves the list is exact (every error the full schema reports is on a listed property, and every listed property does fail): `BookingBase` types `adults`, `party`, `extras`, `payment_status`, `price` and `taxes` as strings while the guide's booking example and the `POST /booking/` response schema show numbers, objects and arrays (the fixtures follow the example and pass that response schema in full); `PitchTypeBase` types `pitches` and `lead_price` as strings and `ground_type` as non-nullable, where the `GET /pitchtype/{pk}/` schema and example use an array, a number and null; `PitchBase` types `calendar_feeds` as a string where the guide and the `POST /pitch/` schema use an array.
2. Starts a local mock of the API under `/rest/api/` that serves those fixtures with the documented `Authorization: Token <key>` check and DRF-style pagination (`next`/`previous` links, `count` on page-number lists, a cursor on bookings as in the guide's booking example, `page_size` honoured up to 100, booking pages capped at 3 by the mock so the suite pages), 401s with the guide's messages for a missing key, a wrong key and "Token" typed twice, 404 `Not found.` for unknown IDs, 405 for other methods, and injected 429s, 5xx gateway pages, a 400 and an HTML 200. The mock's list, detail and write responses are validated against the spec's response schemas (list envelopes with `next`/`previous` allowed to be null, as the guide's "Pagination" section says), the write request examples from the spec are posted to it, and its error bodies are validated against a schema written by hand (`test/schemas.mjs`), because the spec documents no error response: the messages are the ones the guide's troubleshooting table quotes, and the suite checks each one appears in the spec.
3. Starts the built server and drives it over stdio with the official MCP client: 27 checks covering tools/list and the annotations (14 read tools read-only; 4 write tools destructive and idempotent), every read tool, pagination following `next` across three pages of 100 arrival days and four cursor pages of bookings to a null `next`, an empty page with a `next` link followed, a `200` with non-list JSON reported as an error, the booking filters (the names are read from the guide's "Filtering bookings" section and compared with the tool's inputs, and each of the 20 is passed through exactly: `status` as its numeric key, `pitch` as the pitch ID, a datetime with a space sent as `%20`), a list cut short by `max_results` saying so (unfiltered, and after this server's own pitch type, charge type and campsite filters in all five tools that have one), a `GET /pitchtype/{pk}/` answer holding another pitch type (as a list and as an object) refused by `get_pitch_type`, `get_pricing`, `check_availability` and `set_allocation` with no request after it, and `set_allocation` refusing a record whose `url` points at another pitch type, requests within one tool call spaced at least 240 ms apart, the `date`/`after`/`before` filters on arrival and allocation days, the `external_id` filter on pitches, the spec's list-shaped `GET /pitchtype/{pk}/` answer and a plain object, redaction of guest and campsite contact details and calendar links by default and their return on request (including phone numbers written `(01632) 960555`, `+44 (0)7700 …`, `0044 …` and `7700900123` in free text), a booking on a pitch type that requires the registration and party names in special requests (registration, postcode, card number and mobile redacted by default, names and street returned as typed, the card number redacted even on request), card and payment fields never returned in either case, `list_arrivals` filtering and totals (a `Confirmed` status counted as confirmed, a booking without `party.dogs` not counted as 0 dogs, and a `null` dog total when no booking has one), `check_availability` naming a night without an allocation day and a night with nothing left, refusing 91 nights and accepting 90, stopping after 30 pages of allocation days and then reporting the result as partial, `list_extras` capping the variable prices at `max_results`, the write gate with the variable unset and `false`, every write body validated against the spec's request schema (`POST /allocation/` as one object and as a list, `POST /pitchtype/{pk}/allocation/` with `max_allocation` and with `allocation`, `POST .../pricing/` against its request schema and `ChargeTypePricingBase`, the whole-record `PUT /chargetype/{id}/`), local refusals before any call (reversed ranges, a repeated date, `min_days` above `max_days`, both or neither allocation field, nothing to change, a price that is not a decimal string), bad IDs and dates rejected before any call, the 404 and 401 messages (a 401 sent once, not retried), a 400 passed on redacted, an error body that echoes the key passed on with the key scrubbed and at most six messages, the 429 retry waiting for `Retry-After` in the whole-seconds, fractional-seconds and HTTP-date forms and 2 s then 4 s when it is absent, giving up after three attempts and at once above the cap, a 429 on a write retried once, a 502 retried for `GET` and a 503 on a write not retried, a `GET` failing three times reported without the HTML, a non-JSON 200 reported as an error without quoting it, a `next` link on another host not followed, a key pasted with `Token ` sent once, and that every request carried `Authorization: Token <key>`, `Accept: application/json; version=2023-08-25` and a documented method and path (the spec's `paths` plus `GET /rest/api/pitchtype/`, which the suite checks is written in the guide). One more check reads the environment rules (sandbox default, live, bad values refused) from the built `dist/config.js` without any network call.
The fixtures avoid `null` in fields the spec does not mark nullable, although the guide's examples show `null` in several of them (`external_id`, `arrival_time`, `cancelled_at`, `name` and `notes` on pitches, many campsite fields); the server treats `null`, an empty string and the string `"null"` the same way.
## Status
This is a working prototype. It has **not yet been run against the live API or the sandbox**, because it was built without a Pitchup account. Everything below is taken from the published guide and should be confirmed on a sandbox account (the sandbox sign-up is self-serve; test bookings need Pitchup to activate the listing):
- The error responses: the status codes and bodies for a missing or wrong key (the mock answers 401 `{"detail": "Invalid token."}`), for an unknown ID (404 `Not found.`), for validation errors (the 400 body shape is not documented; the server reads any JSON strings in it) and for rate limiting (no 429 is documented at all).
- The API version: that `Accept: application/json; version=2023-08-25` is honoured, and which field names and status values come back under it (the guide's booking example shows `"status": "paid"`, which version 2019-01-21 says became `confirmed`).
- `GET /rest/api/pitchtype/`: it is documented in the guide's text and the API root's example, not in the spec's `paths`. Whether it accepts a campsite filter (the guide says "optionally filtered by campsite" without naming the parameter; this server filters by the campsite link itself).
- `GET /rest/api/pitchtype/{pk}/`: the spec documents a paginated list as its answer (and whether that list can hold other pitch types is not said); the server takes the record with the requested ID from `results`, or accepts a plain object with that ID, and treats an answer without that ID as not found.
- Pagination: that `page_size` up to 100 is honoured on every list, that `next` links point to the same host the request went to (on the sandbox, the sandbox host; the guide's examples all use `www.pitchup.com`), which lists use cursors and which page numbers, and whether the API root answers an object (spec schema) or a one-element array (guide example); both are handled.
- Filter semantics: whether `after` and `before` on arrival and allocation days compare the `date` and include it (`check_availability` asks for one day more on each side and picks the nights itself, so either reading works); whether allocation days accept `date`, `after` and `before` at all (the guide documents them for arrival days and names them in its general filtering section, but its allocation section lists no filters); that booking `after`/`before` compare the creation date, as the guide says, whether they include the given time, and that they accept `YYYY-MM-DD HH:MM:SS`; that `status` takes the numeric key (the guide's example is `status=3`) and `pitch` the pitch ID; that `first_name`/`last_name` match "containing" case-insensitively.
- Booking status spelling: the status values list and the 2019-01-21 changelog write `confirmed`, the booking response table's example value is `"Confirmed"`; `list_arrivals` compares case-insensitively, so either works, and shows the status as sent.
- Dogs: the guide lists `dogs` in `party` under "What's new in version prerelease", and this server pins `2023-08-25` by default, so the booking API may send no dog count at all. `list_arrivals` then reports the number of dogs as unknown (`null`, or a total of the bookings that do carry one, with a note) rather than 0. Whether `party.dogs` comes back under `2023-08-25` is unconfirmed; `PITCHUP_API_VERSION=prerelease` should include it.
- Booking field shapes: `party`, `payment_status`, `extras` and `taxes` as objects and arrays, as in the guide's example and the `POST /booking/` schema rather than `BookingBase`. The shape of an item in `extras` is not shown anywhere (the example list is empty; the response table names `extra`, `extra price` and `quantity`); the server reads `extra` or `name`, `quantity` and `price`, `extra price` or `extra_price`.
- The schema disagreements listed under Tests (`BookingBase`, `PitchTypeBase`, `PitchBase`), the `ExtraBase` status enum (`active`, `deleted`, `disabled`) against the guide's example value `inactive`, and which fields are really nullable.
- Writes, all untested on a real account: that `POST /rest/api/allocation/` accepts the pitch type's own `url` as `pitchtype` and a JSON list of up to 90 days, and what it answers for a list (the guide's example response is a paginated list; the server accepts a list, a paginated list or one object); that `POST /pitchtype/{pk}/allocation/` accepts `max_allocation` (documented in the guide's text and response table, not in the request schema); that `POST .../pricing/` takes `is_soft_close` and `closed_to_departure` as JSON booleans and `weekdays` as integers (the guide's examples quote them as strings, the spec's example quotes weekdays as strings); that the `task_id` jobs apply as described (a job's result can only be queried in the prerelease version); and that `PUT /chargetype/{id}/` accepts `name`, `description`, `pitchtype` and `is_active` alone, and what it does to `status` when `is_active` changes.
- Past dates: the guide says updates to arrival and allocation days in the past are refused, using Europe/London time; the server does not check this itself.
- How many requests per second the API tolerates; nothing is documented, so the 250 ms spacing is a guess.
## Going to production
This version runs locally over stdio, with the site owner's own API key. For site owners to connect from claude.ai or ChatGPT without handling keys, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by Pitchup, and then a listing in the Claude and ChatGPT connector directories. A production version, tested on a sandbox site with an activated listing, would also cover the booking amendment and Reserved-booking endpoints, the `task_id` status check once it leaves prerelease, and webhooks for new bookings and cancellations.
## Licence
MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.
TDQS
Scored across 14 tools
Each tool targets a fairly distinct resource (campsites, pitch types, pitches, charge types, pricing, bookings, arrivals, allocations, extras). There is mild overlap between list_allocations and check_availability (both concern allocation) and between list_arrivals and list_bookings (arrivals is a filtered view of bookings), but the detailed descriptions clarify the boundaries well enough.
Most tools follow a clean get_/list_ + noun pattern (get_campsite, list_pitches, get_pricing, list_bookings). Minor deviations exist: api_root is a bare noun and check_availability uses a different verb, but the set still reads as one coherent convention.
14 tools is well within the ideal range and each maps to a distinct resource or operation in the booking-management domain. No tool appears redundant or padded.
The surface covers the full read lifecycle: account/environment, campsites, pitch types, pitches, charge types, pricing, allocations, availability, bookings, arrivals and extras. It appears intentionally read-only (no create/update/delete), which is likely by API design, though agents cannot mutate bookings or campsites. A direct get_booking for a single booking is also absent, but list_bookings largely compensates.