Restaurant Reservations MCP Server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Restaurant Reservations MCP Serverbook a table for 2 at 7pm on September 20"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Want to check every claim in this README yourself? 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.
# 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 upThat 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.
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
.envneeds./vendor/bin/sail restart: Octane holds the environment in memory between requests, so a running worker will not see the edit.
Related MCP server: Restaurant Reservation MCP Server
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
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:
{
"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:
{
"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 |
| Table booked; |
| The slot exists but cannot seat this party. Offer |
| Never bookable as asked — closed, in the past, off the 30-minute grid, or a party too large. |
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
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):
claude mcp add --transport http reservations http://localhost/mcp/reservationsClaude Desktop (stdio transport, via the same server registered in routes/ai.php):
{
"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:
./vendor/bin/sail artisan mcp:inspector mcp/reservationsFrom 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.
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"
}
}
}]
}
}'{
"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 (withargumentsas an object or a JSON string) and Retell's flat{name, args, call}body both work, and the reply carries Vapi'sresultsenvelope 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_keyWhy 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 |
The MCP tool: input schema, validation, output schema | |
Server definition and the instructions agents read | |
The Redis Lua capacity gate | |
Booking flow, idempotency, compensation | |
The requested booking, typed; owns the field→column mapping | |
Slot grid, service hours, candidate alternatives | |
The single source of truth: every setting and every input rule | |
Every restaurant rule, all env-backed | |
Boot: key, migrate, then Octane | |
Voice platform payload → the same service | |
Webhook authentication, fails closed | |
Optional |
Tests
./vendor/bin/sail artisan test23 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:
# 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 ramps 10 → 200 VUs over 70 s; 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 | 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:verifyconfirmed 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 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.
laravel/ai. With RESERVATIONS_AI_PHRASING=true, the "fully booked" sentence is rewritten
by a HostAgent 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:verifyto 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/mcpsupports 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 and config/reservations.php.
Variable | Default | Meaning |
|
| Timezone all dates/times are interpreted in |
|
| First and last bookable seating |
|
| Spacing of the slot grid |
|
| How long a table is held (informational) |
|
| Capacity per start time |
|
| Largest party bookable online |
|
| How far either side to search (4 × 30 min = ±2 h) |
|
| TTL of the availability read cache |
|
| Per-IP cap on the MCP endpoint |
| (empty) | Shared secret for the voice webhook; empty disables it |
|
| Per-IP cap on the voice webhook |
|
| Let |
|
| How long a generated sentence is reused |
|
| Swoole workers ( |
This server cannot be deployed
Related MCP Connectors
Find Resy restaurants and request reservations through Scout; the user approves every booking.
Book hard-to-get restaurant reservations on your own Resy, SevenRooms, or OpenTable account.
Book a table, appointment or class at a real local business. Instant confirmation, no API key.
Last-minute booking slots across 11 suppliers. Search, price, and execute bookings via AI agents.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables restaurant reservation management through SevenRooms API, allowing users to create reservations and query available time slots with guest details and party size information.-
- FlicenseBqualityFmaintenanceEnables users to search, check availability, and book restaurant reservations across Resy and OpenTable platforms. It supports direct booking for Resy and includes an automated reservation 'sniper' for securing high-demand slots the moment they become available.124-
- AlicenseAqualityCmaintenanceEnables AI agents to search restaurants, check availability, and book reservations on OpenTable, including managing booking history and handling multi-factor authentication.945MIT
- AlicenseAqualityDmaintenanceEnables AI agents to search restaurants, check availability, and book reservations on OpenTable, with persistent sessions and MFA handling.9451MIT