restaurant-mcp
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-mcpbook a table for 4 this Friday at 7pm under Ada Lovelace"
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. Every call answers three things:
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.
It is built to absorb many concurrent calls: Octane/Swoole keeps the framework booted in memory, and the capacity check is a single atomic Redis operation, so the same seat can never be sold twice.
Measured: 1,584 req/s with zero errors (p95 140 ms), and exactly 40 of 40 seats sold when 12,001 requests fought over one slot. Full method and numbers in How I tested performance and what the results were.
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.
Contents
The sections the brief asks for come first, then how to connect an AI agent over MCP, then the constraints and bonus items, then deeper reference material.
The brief, answered directly
How this submission maps to the brief
Reference
Related MCP server: @striderlabs/mcp-opentable
How to start the project with Sail
Requirements: Docker only (Docker Desktop on macOS/Windows). No local PHP, Composer or Node is needed — everything runs inside the Sail containers.
# 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 upOn the one manual step.
vendor/is not committed, so step 1 installs dependencies once via a throwaway container — no local PHP or Composer required. Beyond that single command and copying.env, there is no manual setup: migrations and key generation happen automatically on boot, so adding environment variables is all that stands between a clone and a running server.
That is the whole setup. On boot the container:
generates
APP_KEYif the env file has none,waits for MySQL to accept connections,
runs migrations,
and starts Octane (Swoole).
So there is no separate ./vendor/bin/sail artisan migrate step and no second terminal — a
single sail up gives you a working server. The boot sequence lives in
docker/start-app.sh if you want to read exactly what happens.
The MCP endpoint is then available at http://localhost/mcp/reservations. A quick smoke test
that lists the exposed tools:
curl -s -X POST http://localhost/mcp/reservations \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Common follow-up commands:
./vendor/bin/sail artisan test # run the test suite
./vendor/bin/sail artisan reservations:verify # audit Redis counters against MySQL
./vendor/bin/sail down # stop everything
./vendor/bin/sail down -v # stop and wipe the MySQL/Redis volumesNote on
.envchanges. Octane holds the environment in memory between requests, so editing.envwhile the server is running has no effect until you restart it with./vendor/bin/sail restart.
How to call the MCP tool manually
Two tools are exposed on the reservation server. Both are plain JSON-RPC 2.0 over a single HTTP
POST — no session handshake is required, which keeps curl examples simple and also makes the
endpoint straightforward to load test.
Tool | Purpose |
| Book a table (or be told why it can't be booked, with alternatives). |
| List every bookable start time on a date and the seats left in each. |
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"
}
}
}'Arguments
Field | Required | Rule |
| yes | string, 2–120 chars |
| yes | valid email, ≤180 chars |
| no | string, ≤40 chars |
| yes | integer, 1–12 |
| yes |
|
| yes |
|
| no | string, ≤500 chars |
| no | string, ≤64 chars; a repeat with the same key returns the original booking |
Input is validated before anything is booked; failures come back as an MCP tool error listing exactly what was wrong.
Confirmed response
{
"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 values
Status | Meaning |
| Table booked; |
| The slot exists but cannot seat this party right now. Offer |
| Never bookable as asked — closed, in the past, off the 30-minute grid, or a party too large. |
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 on that date with the number of seats still free in it (filtered
to those that can seat the requested party_size).
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.
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.
Setting up the MCP with an agent (Claude, Codex, and others)
This is how an AI agent — Claude, Codex, or any MCP-compatible client — connects to the two reservation tools and books on a guest's behalf.
Both tools are served two ways from routes/ai.php: over HTTP at
http://localhost/mcp/reservations (simplest for any client that speaks streamable HTTP) and over
stdio via php artisan mcp:start reservations (for clients that launch a local process). The
server runs inside the container, so start the stack before connecting any client:
./vendor/bin/sail up -dIn the examples below, replace
/absolute/path/to/reservationswith the absolute path to your clone (e.g. the output ofpwd).
Claude Code (HTTP — recommended)
The least fiddly option, because it just points at the running HTTP endpoint:
claude mcp add --transport http reservations http://localhost/mcp/reservations
claude mcp list # expect: reservations ... ✓ ConnectedThen start claude, type /mcp to confirm the two tools loaded, and ask in plain English, e.g.
"book a table for 2 tomorrow at 7pm for Ada, ada@example.com". Remove it later with
claude mcp remove reservations.
Claude Desktop (stdio)
Edit Claude Desktop's config file — ~/Library/Application Support/Claude/claude_desktop_config.json
on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows — and add a reservations
server inside mcpServers (keep any existing keys):
{
"mcpServers": {
"reservations": {
"command": "docker",
"args": [
"compose",
"-f", "/absolute/path/to/reservations/compose.yaml",
"exec", "-T",
"laravel.test",
"php", "artisan", "mcp:start", "reservations"
],
"env": { "WWWGROUP": "1000", "WWWUSER": "501" }
}
}
}Then fully quit Claude Desktop (Cmd+Q / Quit, not just close the window) and reopen it. The
reservations connector should report connected, with create-reservation and check-availability
available.
Why call docker compose directly instead of ./vendor/bin/sail? A desktop client launches the
command from its own working directory, not the project, so a relative ./vendor/bin/sail — and
Sail's implicit "find compose.yaml in the current directory" — fails with
no configuration file provided: not found followed by Sail is not running. Pinning the compose
file with -f <absolute path> makes the working directory irrelevant. -T disables the pseudo-TTY
that would otherwise corrupt the JSON-RPC stream, and the two env values silence Sail's
WWWGROUP/WWWUSER warnings. The containers must already be running (sail up -d) whenever the
client launches; after a sail down or reboot, bring them back up and relaunch the client.
Codex CLI (stdio)
Codex reads ~/.codex/config.toml. Add the server there:
[mcp_servers.reservations]
command = "docker"
args = [
"compose",
"-f", "/absolute/path/to/reservations/compose.yaml",
"exec", "-T",
"laravel.test",
"php", "artisan", "mcp:start", "reservations",
]
[mcp_servers.reservations.env]
WWWGROUP = "1000"
WWWUSER = "501"Or register it from the command line:
codex mcp add reservations -- \
docker compose -f /absolute/path/to/reservations/compose.yaml \
exec -T laravel.test php artisan mcp:start reservationsThe same rules as Claude Desktop apply: containers up first, -f <absolute> so the working
directory does not matter, and -T to keep stdout clean. Recent Codex builds can also connect over
HTTP instead of stdio — point those at http://localhost/mcp/reservations.
MCP Inspector
To poke at the tools interactively without any client, use the bundled inspector:
./vendor/bin/sail artisan mcp:inspector mcp/reservationsHow I tested performance and what the results were
Method
Load was generated with k6 running 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 measured 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 with the rate limit deliberately lowered is worth reporting because it shows the
throttle working: throughput flat-lined at the cap (6,000/min × 2 windows) with the rest returning
429. The limit is a safety valve, not a bottleneck — see the tradeoff in
Assumptions or tradeoffs.
Assumptions or 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 but not deducted from later slots.
ends_atis stored on the reservation, but a 19:00 booking does not consume 19:30 capacity. Modelling overlap properly means claiming every slot a turn spans — a straightforward extension of the same Lua script, but it 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.
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 claiming a seat and inserting the row would leak seats until that counter is rebuilt.The rate limit default is deliberately high (
300000/min) so that load testing measures the application rather than the throttle. 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 on 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.
Constraints
How this submission respects each constraint:
Must be a Laravel project, reproducible with Laravel Sail. It is a standard Laravel 13 application (PHP 8.3+, running on the PHP 8.5 Sail runtime);
sail upbuilds and runs the whole stack (app + MySQL + Redis) with no local toolchain.The core deliverable is the MCP tool — keep everything else as small as possible. The reservation domain is a handful of small, single-purpose classes; there is no UI, no auth scaffolding, and the default Laravel example tests and the
inspireconsole stub have been removed. Everything beyond the tool (voice webhook, AI phrasing) is optional and isolated.External services can be mocked. The
laravel/aiphrasing layer is off by default and falls back to a written message, and the voice webhook can be exercised locally withcurlwithout any Vapi/Retell account. Redis and MySQL run inside Sail.
Bonus features
Each optional bonus item, and where it lives:
laravel/ai. WithRESERVATIONS_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 bySlotCalendarandSlotAvailability, and the model only phrases that decided list, so a bad generation is a clumsy sentence rather than a wrong booking. It is cached per slot and party size, capped at 40 words, falls back to the written message on any error, and is off by default (it would otherwise put a network call on the fastest path in the app and make the benchmarks untrue). Enabling it needs a provider key such asANTHROPIC_API_KEY.Voice agent integration (Vapi / Retell).
POST /webhooks/voice/reservationaccepts both platforms' function-call payloads against the same reservation service — see From a voice agent. Signatures are verified and fail closed.Caching.
check-availabilityreads 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.Rate limiting. The MCP route carries
throttle:mcp, defined in AppServiceProvider and keyed by IP, with the limit fromMCP_RATE_LIMIT_PER_MINUTE; responses carryX-RateLimit-Limit/X-RateLimit-Remaining. The voice webhook gets its own, far tighterthrottle:voice— a phone line makes a handful of calls a minute, not thousands.How I would scale this. A dedicated section: How this would scale.
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 test21 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 (including past dates), 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.
Static analysis and formatting are also wired up: composer analyse (PHPStan/Larastan level 6) and
composer test:lint (Pint), and both run in CI (.github/workflows/ci.yml).
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.
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
Maintenance
Related MCP Connectors
Last-minute booking slots across 11 suppliers. Search, price, and execute bookings via AI agents.
Book a table, appointment or class at a real local business. Instant confirmation, no API key.
Discover and book businesses via AI agents.
Find availability and book across a network of independent venues via your AI assistant.
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.-
- 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
- AlicenseNot gradedqualityBmaintenanceEnables managing restaurant reservations over Streamable HTTP, with tools for booking, checking availability, cancelling, resources for reservation data, and prompts for drafting confirmations.MIT