Restaurant Reservations MCP Server
README.md
# Restaurant Reservations — MCP Tool
A small Laravel application that exposes restaurant booking as an **MCP tool**, so a customer
(or an AI agent acting for them) can reserve a table. It answers three things per call:
- whether the requested date and time can be seated,
- the confirmed reservation (reference, date, time, party size),
- the nearest alternative times when the requested slot cannot take the party.
Built to absorb many concurrent calls: Octane/Swoole keeps the framework in memory, and the
capacity check is a single atomic Redis operation, so the same seat can never be sold twice.
**Measured: 1,730 req/s with zero errors (p95 127 ms), and exactly 40 of 40 seats sold when
12,001 requests fought over one slot.** Details in [Performance](#performance).
---
> Want to check every claim in this README yourself? [VERIFY.md](VERIFY.md) is a
> copy-paste checklist: each requirement from the brief, the command that proves it, and what a
> pass looks like.
## Quick start
Requirements: Docker (Docker Desktop on macOS/Windows). Nothing else — no local PHP needed.
```bash
# 1. Install PHP dependencies using a throwaway container (only needed once)
docker run --rm -v "$(pwd)":/var/www/html -w /var/www/html \
laravelsail/php84-composer:latest composer install --ignore-platform-reqs
# 2. Create your environment file
cp .env.example .env
# 3. Start everything
./vendor/bin/sail up
```
That is the whole setup. On boot the container generates `APP_KEY` if the env file has none,
waits for MySQL, runs migrations, and starts Octane — so there is no `sail artisan migrate`
step and no second terminal.
The MCP endpoint is then at **`http://localhost/mcp/reservations`**.
```bash
curl -s -X POST http://localhost/mcp/reservations \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
> Changing a value in `.env` needs `./vendor/bin/sail restart`: Octane holds the environment in
> memory between requests, so a running worker will not see the edit.
---
## Calling the tool manually
Two tools are exposed. Both are plain JSON-RPC 2.0 over a single HTTP POST — no session
handshake is required, which also makes them straightforward to load test.
### `create-reservation`
```bash
curl -s -X POST http://localhost/mcp/reservations \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create-reservation",
"arguments": {
"customer_name": "Ada Lovelace",
"customer_email": "ada@example.com",
"customer_phone": "+44 7700 900123",
"party_size": 2,
"date": "2026-09-20",
"time": "19:00",
"special_requests": "Window table if possible",
"idempotency_key": "booking-attempt-001"
}
}
}'
```
Confirmed:
```json
{
"status": "confirmed",
"message": "Table confirmed for 2 on Sunday 20 September at 7:00 PM. 38 seat(s) left in that slot.",
"reservation": {
"reference": "RSV-LZEZUGIXZ8",
"date": "2026-09-20",
"time": "19:00",
"ends_at": "20:30",
"party_size": 2,
"customer_name": "Ada Lovelace",
"customer_email": "ada@example.com",
"timezone": "UTC"
},
"alternatives": [],
"already_booked": false
}
```
Slot full — the agent is handed times it can offer instead:
```json
{
"status": "unavailable",
"message": "7:00 PM on Sunday 20 September is fully booked for a party of 2.",
"reservation": null,
"alternatives": [
{ "date": "2026-09-20", "time": "19:30" },
{ "date": "2026-09-20", "time": "18:30" },
{ "date": "2026-09-20", "time": "20:00" }
],
"already_booked": false
}
```
`status` is one of:
| Status | Meaning |
| --- | --- |
| `confirmed` | Table booked; `reservation` is populated. |
| `unavailable` | The slot exists but cannot seat this party. Offer `alternatives`. |
| `rejected` | Never bookable as asked — closed, in the past, off the 30-minute grid, or a party too large. `message` explains which. |
Input is validated before anything is booked (required fields, email format, `YYYY-MM-DD`,
`HH:MM`, party size 1–12); failures come back as an MCP tool error listing what was wrong.
### `check-availability`
```bash
curl -s -X POST http://localhost/mcp/reservations \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check-availability","arguments":{"date":"2026-09-20","party_size":4}}}'
```
Returns each bookable start time with the seats left in it.
### From an MCP client
**Claude Code** (HTTP transport):
```bash
claude mcp add --transport http reservations http://localhost/mcp/reservations
```
**Claude Desktop** (stdio transport, via the same server registered in `routes/ai.php`):
```json
{
"mcpServers": {
"reservations": {
"command": "./vendor/bin/sail",
"args": ["artisan", "mcp:start", "reservations"],
"cwd": "/absolute/path/to/this/project"
}
}
}
```
Or poke at it interactively with the official inspector:
```bash
./vendor/bin/sail artisan mcp:inspector mcp/reservations
```
### From a voice agent (Vapi / Retell)
A phone agent can book through `POST /webhooks/voice/reservation`, which maps the platform's
function-call payload onto the *same* `ReservationService` — so capacity, alternatives and
idempotency behave identically whether the guest typed or spoke. Set `VOICE_WEBHOOK_SECRET`
first; an empty secret disables the endpoint rather than leaving it open.
```bash
curl -s -X POST http://localhost/webhooks/voice/reservation \
-H 'Content-Type: application/json' \
-H 'X-Vapi-Secret: local-voice-secret' \
-d '{
"message": {
"type": "tool-calls",
"call": { "id": "call_123" },
"toolCalls": [{
"id": "toolcall_abc",
"function": {
"name": "create_reservation",
"arguments": {
"customer_name": "Phone Caller",
"customer_phone": "+44 7700 900999",
"party_size": 3,
"date": "2026-09-20",
"time": "21:00"
}
}
}]
}
}'
```
```json
{
"results": [{
"toolCallId": "toolcall_abc",
"result": "Table confirmed for 3 on Sunday 20 September at 9:00 PM. 37 seat(s) left in that slot. The reference is R S V B R U 8 R K E C 5 0."
}],
"response": "Table confirmed for 3 ...",
"status": "confirmed",
"reference": "RSV-BRU8RKEC50",
"alternatives": []
}
```
Details that matter on a phone call:
- **Both platforms are accepted.** Vapi's `message.toolCalls[]` envelope (with `arguments` as an
object *or* a JSON string) and Retell's flat `{name, args, call}` body both work, and the reply
carries Vapi's `results` envelope alongside the top-level fields Retell reads.
- **Signed, or refused.** Vapi's shared-secret header (`X-Vapi-Secret`) and Retell's body HMAC
(`X-Retell-Signature`) are both verified, with timing-safe comparisons.
- **Email is optional here** — a caller has a phone number. At least one contact method is
required. The MCP tool still requires an email.
- **The tool-call id becomes the idempotency key**, so a replayed turn cannot book twice.
- **References are spelled out** (`R S V B R U 8…`) so text-to-speech reads characters rather
than inventing a word.
- Validation failures come back as a spoken sentence with HTTP 422, so the agent can simply ask
the caller again.
To test against the real platform locally, expose the app with a tunnel
(`cloudflared tunnel --url http://localhost`) and point the platform's function URL at it.
---
## How it works
```
MCP client ──POST /mcp/reservations──▶ Octane/Swoole worker (app stays booted in memory)
│
throttle:mcp ──┤
▼
CreateReservationTool validation + MCP schema
▼
ReservationService the only place bookings happen
┌───────────┴────────────┐
▼ ▼
SlotAvailability (Redis) Reservation (MySQL)
Lua: check + INCRBY durable record, unique
in ONE atomic call idempotency_key
```
**Why it is safe under concurrency.** Seats sold per slot live in a Redis counter, and the only
thing allowed to move that counter is a Lua script that reads capacity, compares, and increments
inside a single Redis call. Redis executes scripts atomically, so two workers chasing the last
two seats cannot both win — there is no read-then-write window to lose, and no database row lock
to serialise on. The reservation row is written to MySQL afterwards; if that write fails the
seats are released again.
**Why MySQL still matters.** Redis is a fast gate, not the record. If a counter is missing
(Redis restarted, key expired), it is rebuilt from `SUM(party_size)` in MySQL and published with
`SET ... NX`, so the first worker through wins the race and later ones do not clobber claims made
in between. A test covers exactly this (`test_availability_counters_survive_a_cold_cache`).
**Alternatives** are found by asking Redis for the surrounding slots in one `MGET`, then keeping
the closest ones that still fit the party — one round trip, not one per candidate.
**Idempotency.** `idempotency_key` is a unique column. A retrying agent gets the original
reservation back (`already_booked: true`) instead of a second table, and a race on the same key
is caught at the constraint and compensated.
### Files worth looking at
| Path | What it holds |
| --- | --- |
| [app/Mcp/Tools/CreateReservationTool.php](app/Mcp/Tools/CreateReservationTool.php) | The MCP tool: input schema, validation, output schema |
| [app/Mcp/Servers/ReservationServer.php](app/Mcp/Servers/ReservationServer.php) | Server definition and the instructions agents read |
| [app/Services/Reservations/SlotAvailability.php](app/Services/Reservations/SlotAvailability.php) | The Redis Lua capacity gate |
| [app/Services/Reservations/ReservationService.php](app/Services/Reservations/ReservationService.php) | Booking flow, idempotency, compensation |
| [app/Services/Reservations/PendingReservation.php](app/Services/Reservations/PendingReservation.php) | The requested booking, typed; owns the field→column mapping |
| [app/Services/Reservations/SlotCalendar.php](app/Services/Reservations/SlotCalendar.php) | Slot grid, service hours, candidate alternatives |
| [app/Services/Reservations/RestaurantPolicy.php](app/Services/Reservations/RestaurantPolicy.php) | The single source of truth: every setting and every input rule |
| [config/reservations.php](config/reservations.php) | Every restaurant rule, all env-backed |
| [docker/start-app.sh](docker/start-app.sh) | Boot: key, migrate, then Octane |
| [app/Http/Controllers/VoiceReservationController.php](app/Http/Controllers/VoiceReservationController.php) | Voice platform payload → the same service |
| [app/Http/Middleware/VerifyVoiceSignature.php](app/Http/Middleware/VerifyVoiceSignature.php) | Webhook authentication, fails closed |
| [app/Services/Reservations/AlternativePhrasing.php](app/Services/Reservations/AlternativePhrasing.php) | Optional `laravel/ai` wording layer |
---
## Tests
```bash
./vendor/bin/sail artisan test
```
23 feature tests, driving the tools through the MCP layer itself and the webhook over HTTP:
- **Booking** — a slot that fills exactly to capacity and not one seat further, alternatives when
full, idempotent retries, rejected times, input validation, counter rebuilding after Redis is
flushed.
- **Voice** — both platform payload shapes, signature verification (unsigned, mis-signed and
unconfigured all refused), contact-detail rules, spoken validation errors, replayed turns.
- **AI phrasing** — that an enabled model rewrites only the sentence and never the offered times,
and that a failing or rambling provider falls back to the written message.
---
## Performance
### Method
Load generated with **k6** in Docker, attached to the app's own Compose network so the numbers
measure the application rather than Docker Desktop's port forwarding:
```bash
# Throughput: bookings spread across the calendar
docker run --rm -i --network test_sail -v "$PWD/load-test:/scripts" \
-e BASE_URL=http://laravel.test grafana/k6 run /scripts/spread.js
# Contention: every request fights for the SAME slot
docker run --rm -i --network test_sail -v "$PWD/load-test:/scripts" \
-e BASE_URL=http://laravel.test grafana/k6 run /scripts/hot-slot.js
# Then prove nothing was oversold
./vendor/bin/sail artisan reservations:verify
```
(`-e BASE_URL=http://host.docker.internal` without `--network` also works, via the published port.)
Between runs the state was reset with `sail artisan migrate:fresh --force` and
`sail exec redis redis-cli flushall`.
Hardware: MacBook, 11 CPUs available to Docker, 8 GB VM. Octane ran 11 Swoole workers.
[spread.js](load-test/spread.js) ramps 10 → 200 VUs over 70 s; [hot-slot.js](load-test/hot-slot.js)
holds a constant 400 requests/s for 30 s against a single slot.
### Results
| Scenario | Requests | Throughput | Avg | p95 | Max | Errors / timeouts |
| --- | --- | --- | --- | --- | --- | --- |
| Spread, 40 seats/slot | 110,903 | **1,584 req/s** | 84 ms | 140 ms | 796 ms | **0** |
| Spread, capacity raised (every call writes a row) | 87,180 | **1,245 req/s** | 107 ms | 171 ms | 936 ms | **0** |
| Hot slot, 400 req/s at one slot | 12,001 | 400 req/s absorbed | 4.4 ms | 3–15 ms | 312 ms | **0** |
| *Baseline:* Sail's default `artisan serve` | 8,330 | 119 req/s | 1.13 s | 1.74 s | 2.07 s | 0 |
Reading the table:
- **Throughput.** ~111,000 tool calls in 70 seconds with no failed request and no timeout (two
consecutive runs: 1,584 and 1,559 req/s). The second row is the harsher version of the same
test: capacity was raised so that *every* call booked a table, i.e. 87,180 rows written to
MySQL, still with zero errors.
- **Correctness under contention.** 12,001 requests aimed at one 40-seat slot in 30 seconds sold
**exactly 40 seats** (20 parties of two) — not 39, not 41. The other 11,981 were turned away
cleanly, each with alternative times. `reservations:verify` confirmed it against MySQL:
```
| Slot (UTC) | Seats sold | Bookings | Capacity |
| 2026-09-12 19:00 | 40 | 20 | 40 |
No slot exceeds capacity.
Redis counters match the database.
```
Latency *drops* under contention because a full slot is answered by Redis alone — the rejection
never reaches MySQL. Across six repeat runs the median stayed at ~3.3 ms while the p95 wandered
between 3 ms and 15 ms; that tail is noise from a laptop running Docker, not the application, so
the median is the number to trust here. The seat count came out at exactly 40 every single time,
which is the part that actually matters.
- **Runtime choice.** The same script against Sail's stock PHP development server manages
119 req/s at a p95 of 1.74 s. Octane/Swoole is roughly **14× the throughput at ~1/13th the
latency**, which is why the compose file overrides the serve command.
An earlier run is worth reporting because it shows the throttle working: throughput flat-lined at
exactly 12,000 successes (6,000/min × 2 windows) with the rest returning 429. The limit is a
safety valve, not a bottleneck — see the tradeoff below.
---
## Bonus items
**Rate limiting.** The MCP route carries `throttle:mcp`, defined in
[AppServiceProvider](app/Providers/AppServiceProvider.php) and keyed by IP, with the limit from
`MCP_RATE_LIMIT_PER_MINUTE`; responses carry `X-RateLimit-Limit` / `X-RateLimit-Remaining`. The
voice webhook gets its own, far tighter `throttle:voice` — a phone line makes a handful of calls
a minute, not thousands.
**Caching.** `check-availability` reads through a 5-second Redis cache
(`RESERVATIONS_AVAILABILITY_CACHE_SECONDS`), so repeated "what's free tonight?" questions cost
nothing. Bookings deliberately *never* read that cache — they always go through the atomic claim —
so a stale read can never cause an overbooking.
**Voice agent integration.** `POST /webhooks/voice/reservation` accepts Vapi and Retell function
calls against the same reservation service — see
[From a voice agent](#from-a-voice-agent-vapi--retell).
**`laravel/ai`.** With `RESERVATIONS_AI_PHRASING=true`, the "fully booked" sentence is rewritten
by a [HostAgent](app/Ai/Agents/HostAgent.php) so a turned-away guest hears something warmer than a
template. The design point is what the model is *not* allowed to do: which times to offer is
decided by `SlotCalendar` and `SlotAvailability`, and the model only phrases that decided list, so
a bad generation is a clumsy sentence rather than a wrong booking. It is also cached per slot and
party size, capped at 40 words, and falls back to the written message on any error — and it is
**off by default**, because it would otherwise put a network call on the fastest path in the
application (p95 ~4 ms) and make the benchmarks above untrue. Enabling it needs a provider key
such as `ANTHROPIC_API_KEY`.
---
## How this would scale
The current shape already removes the usual bottleneck: capacity is decided by one atomic Redis
call, not by a database row lock, so concurrent bookings for the same slot do not queue behind
each other in MySQL. Scaling from there is mostly horizontal.
**Next 10×.** Run several Octane containers behind a load balancer. They are stateless — all
shared state is in Redis and MySQL — so this is a replica count, nothing more. MySQL sees one
small INSERT per confirmed booking and nothing at all for a rejection, and availability reads are
absorbed by the cache, so a single primary carries this comfortably.
**Next 100×.** Two changes. First, move the durable write off the request path: the Redis claim is
what actually reserves the seat, so the row can be written by a queued job and the guest still
gets an immediate, truthful confirmation. That converts bursts into queue depth instead of
database pressure, at the cost of a short window where a reservation exists in Redis but not yet
in MySQL — acceptable for a booking, not for a payment. Second, split reads from writes with a
replica serving the counter-rebuild query and any reporting.
**Beyond that,** slot counters shard naturally: the key is `slot:<timestamp>`, so a Redis Cluster
spreads them across nodes with no coordination between slots, and a multi-venue tenant would
prefix by restaurant. The per-key ceiling is Redis's single-threaded throughput — far past what
any one dining room can physically seat.
**What I would add before any of that:** authentication on the MCP endpoint (Sanctum or OAuth 2.1,
both supported by `laravel/mcp`), per-client rather than per-IP rate limits, and metrics on claim
latency and rejection rate so saturation shows up before guests feel it.
---
## Assumptions and tradeoffs
**Domain simplifications**
- One restaurant, one dining room. Capacity is *seats per start time*, not individual tables, so
there is no table assignment or table-joining logic.
- A 90-minute turn is recorded on the reservation (`ends_at`) but is **not** deducted from later
slots: a 19:00 booking does not consume 19:30 capacity. Modelling overlap properly means
claiming every slot a turn spans, which is a straightforward extension of the same Lua script
but adds multi-key atomicity concerns that felt out of scope here.
- Service hours are a single window per day (17:00–22:00 by default) with no per-weekday rules,
holidays or blackout dates.
- Parties above 12 are rejected and pointed at an events team — a stand-in for the external
system such a booking would really go to.
- Dates and times are restaurant-local wall clock in `RESERVATIONS_TIMEZONE` (UTC by default) and
stored in UTC. There is no per-customer timezone handling.
**Engineering tradeoffs**
- **Redis is the gate, MySQL is the record.** This buys an O(1) capacity check with no hot-row
database lock, at the cost of two systems that must agree. The failure paths are handled
(compensating release, cold-counter rebuild, `reservations:verify` to audit), but a crash in
the microseconds between claim and insert leaks seats until that counter is rebuilt.
- **The rate limit default is deliberately high** (`300000`/min) so that load testing measures
the application. A real deployment would set it far lower and key it per authenticated client
rather than per IP.
- **No authentication on the MCP endpoint.** It is open, which is right for an exercise and wrong
for production; `laravel/mcp` supports Sanctum and OAuth 2.1 middleware on the same route. The
voice webhook *is* authenticated, because an open booking webhook with a public URL is a
different kind of mistake.
- **The AI phrasing layer is deliberately cosmetic and off by default.** It cannot choose times or
book anything, it is cached, and it degrades to the written message — a model is not something I
would put in the decision path of a booking.
- **No queue.** Booking is fully synchronous so the agent gets a real confirmation rather than a
promise. Anything slow that a real system would do here (confirmation email, POS sync) belongs
on a queue; nothing like that is mocked in.
- **Octane means state lives between requests.** Services are container-*bound* rather than
singletons so each resolution re-reads config, and the code holds no static mutable state.
- Node/Vite are untouched — there is no UI, as the exercise does not require one.
---
## Configuration
Everything is env-backed; see [.env.example](.env.example) and
[config/reservations.php](config/reservations.php).
| Variable | Default | Meaning |
| --- | --- | --- |
| `RESERVATIONS_TIMEZONE` | `UTC` | Timezone all dates/times are interpreted in |
| `RESERVATIONS_OPENING_TIME` / `CLOSING_TIME` | `17:00` / `22:00` | First and last bookable seating |
| `RESERVATIONS_SLOT_MINUTES` | `30` | Spacing of the slot grid |
| `RESERVATIONS_TURN_MINUTES` | `90` | How long a table is held (informational) |
| `RESERVATIONS_SEATS_PER_SLOT` | `40` | Capacity per start time |
| `RESERVATIONS_MAX_PARTY_SIZE` | `12` | Largest party bookable online |
| `RESERVATIONS_ALTERNATIVE_WINDOW_SLOTS` | `4` | How far either side to search (4 × 30 min = ±2 h) |
| `RESERVATIONS_AVAILABILITY_CACHE_SECONDS` | `5` | TTL of the availability read cache |
| `MCP_RATE_LIMIT_PER_MINUTE` | `300000` | Per-IP cap on the MCP endpoint |
| `VOICE_WEBHOOK_SECRET` | *(empty)* | Shared secret for the voice webhook; empty disables it |
| `VOICE_RATE_LIMIT_PER_MINUTE` | `120` | Per-IP cap on the voice webhook |
| `RESERVATIONS_AI_PHRASING` | `false` | Let `laravel/ai` phrase the "fully booked" line |
| `RESERVATIONS_AI_CACHE_SECONDS` | `300` | How long a generated sentence is reused |
| `OCTANE_WORKERS` | `auto` | Swoole workers (`auto` = one per CPU) |
This server cannot be deployed