Test your AI agent
Before an AI agent books, moves or cancels anything at a real business, run it against a sandbox. A sandbox answers the MCP server with the same tools and the same booking rules as a live business, and four tools of its own for checking what the agent did. Nothing it does reaches a real person or moves real money.
Why test against a sandbox
- Mistakes stay in the sandbox. A wrong time, a double booking or a cancellation the agent should not have made touches made-up clients only.
- Nothing leaves Bookatu. Every confirmation, reminder and notice the agent causes is kept in the sandbox's test outbox, where the agent, or you, reads it with
sandbox_outbox. - Payments are simulated. A deposit is paid, declined or abandoned with
sandbox_simulate, never with a card, andsandbox_paymentslists every test payment with what became of it. - Every answer says it is a test: the
Bookatu-Livemodeheader isfalseon every answer, andresult._meta["com.bookatu/livemode"]isfalseon every tool result, a refusal included. A successful result'sstructuredContentsayslivemode: falsetoo. - Every run can start from the same place.
sandbox_resetputs the sample data back.
Make a sandbox first, and keep its first test key: see Testing with a sandbox.
Connect an MCP client
The business MCP server has one address for every business, https://bookatu.com/api/mcp (Streamable HTTP, JSON-RPC 2.0, POST only). The business is the one the credential belongs to, so a sandbox's credential reaches that sandbox and nothing else. There are two ways in.
With the sandbox's test key: send it as Authorization: Bearer bk_test_... on every request (or as X-Api-Key). Send Bookatu-Livemode: false too where your client can: a call that would reach a live business, with a live key pasted in by mistake, is then refused before anything runs.
POST https://bookatu.com/api/mcp
Authorization: Bearer bk_test_YOUR_KEY
Bookatu-Livemode: false
Content-Type: application/json
MCP-Protocol-Version: 2025-06-18
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}With OAuth: a client that supports it signs in instead. It registers itself (dynamic client registration), sends you to Bookatu's consent page and gets a token of its own. Pick the sandbox there: your sandboxes are listed beside your businesses, marked (test). The token answer says livemode: false, and so does every MCP answer the connection gets. Tick the box that shares clients' names, emails and phone numbers when the agent should read the outbox (sandbox_outbox needs customers:read). A connected app can call every tool but sandbox_reset, which takes the sandbox's own test key.
A credential or a call of the other mode is refused as a JSON-RPC error, with LIVEMODE_MISMATCH in error.data.code, never as a result:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32000,
"message": "This call asked for test mode (Bookatu-Livemode: false), and this is a live business. Test calls reach sandbox businesses only.",
"data": { "code": "LIVEMODE_MISMATCH", "keyLivemode": false, "businessLivemode": true }
}
}Claude Desktop
Claude Desktop, and Claude in a browser, adds a remote MCP server as a custom connector. In Claude, open Customize > Connectors and choose Add custom connector:
Customize > Connectors > Add custom connector Name: Bookatu sandbox Remote MCP server URL: https://bookatu.com/api/mcp Authentication: Sign in now OAuth client: Register automatically
Claude then sends you to Bookatu: sign in, pick your sandbox (marked (test)), choose read and write, and approve. Where your organization has the Request headers section (a beta in Claude's dialog), you can use the test key instead: choose No sign-in, then add the header authorization with the value Bearer bk_test_YOUR_KEY, the word Bearer and its space included. A header name of your own needs Anthropic's approval first, so Bookatu-Livemode can't be added there; the test key on its own can only ever reach its sandbox.
Claude's own steps: Add a connector that isn't in the directory.
Cursor
Cursor reads MCP servers from .cursor/mcp.json in a project, or ~/.cursor/mcp.json for every project, and fills in ${env:NAME} in a server's url and headers. Keep the key in your environment, never in the file:
{
"mcpServers": {
"bookatu-sandbox": {
"url": "https://bookatu.com/api/mcp",
"headers": {
"Authorization": "Bearer ${env:BOOKATU_TEST_KEY}",
"Bookatu-Livemode": "false"
}
}
}
}Then set BOOKATU_TEST_KEY to your test key in the environment Cursor starts from. Leave headers out and Cursor signs in with OAuth instead (authorization code with PKCE, dynamic client registration), which opens Bookatu's consent page.
Cursor's own reference: Model Context Protocol (MCP).
MCP Inspector
The MCP Inspector is the reference tool for trying an MCP server by hand (it needs Node 22.19 or newer). Its CLI calls one method and prints the answer:
npx @modelcontextprotocol/inspector --cli https://bookatu.com/api/mcp --transport http \ --header "Authorization: Bearer bk_test_YOUR_KEY" \ --method tools/list npx @modelcontextprotocol/inspector --cli https://bookatu.com/api/mcp --transport http \ --header "Authorization: Bearer bk_test_YOUR_KEY" \ --method tools/call --tool-name sandbox_outbox --tool-arg limit=5
Or open its web client on the server with npx @modelcontextprotocol/inspector --server-url https://bookatu.com/api/mcp --transport http, add the Authorization header in the server's settings (or sign in with OAuth), and call the tools from its Tools tab, where each tool's annotations and outputSchema show beside it.
The Inspector's own reference: MCP Inspector.
Things to ask your agent
A short run that goes through a booking from start to end. Ask in plain words, and check each answer against what the tools returned:
- Find a free time: "When could I book a haircut on Tuesday afternoon?" The agent calls
list_servicesandget_availability(andget_businessfor the opening hours), and answers with times from the slots, on the business's clock. - Book it: "Book it for Mia, mia@example.com, +1 613 555 0142."
create_bookinganswers the booking'srefandappointmentId. A sandbox's clients are made up: use an address at example.com and a number in a range kept for fiction. - Check the confirmation: "What was she sent?"
sandbox_outboxshows the message that would have gone out (itskindisbooking_confirmation), with its subject, its text and the booking'sappointmentId. - Simulate the deposit paid: "The client has paid the deposit."
sandbox_simulatewithdeposit.paidpays the booking's open test checkout: the deposit is paid, the answer lists the outbox messages and webhooks it caused, andsandbox_paymentsshows the checkout as paid. A booking needs an open test checkout for this, one opened on the sandbox's booking page or by a deposit the business asked for (POST /api/v1/{slug}/appointmentswithrequestDeposit: true).create_bookingbooks no service that takes a deposit (in the salon sample data, Full colour; a dental clinic's Whitening consultation; a restaurant's Tasting menu): it answersDEPOSIT_NEEDS_BOOKING_PAGEwithbookingPageUrland books nothing, as at a live business, so open that link to book it on the booking page and reach the test checkout there. - Cancel: "Cancel Mia's booking."
cancel_bookingcancels it as the business and says whether a deposit was kept. A cancellation by the business sends the client no message, sosandbox_outboxhas no message to her about it: if she should hear, the agent or the business tells her.sandbox_simulatewithbooking.client_cancelledis the client's own cancellation instead, under the business's window, fees and notices; that one emails the client a cancellation notice, kept in the outbox, when the business has "Email clients when they cancel online" turned on in its settings. - Start again: "Reset the sandbox."
sandbox_reset, with the sandbox's test key only, puts the sample data back. A good agent asks you first.
What the sandbox tools return
| Tool | Scope | Returns |
|---|---|---|
sandbox_outbox | customers:read | messages (newest first: id, channel, kind, to, subject, text, html, appointmentId, customerId, createdAt, meta), nextCursor and livemode: false. |
sandbox_payments | reports:read | payments (newest first: id, kind, outcome, amountCents, refundedCents, currency, description, appointmentId, belongsTo, checkoutSessionId, paymentIntentId, createdAt, updatedAt), nextCursor and livemode: false. |
sandbox_simulate | bookings:write | ok, event, appointment (id, status, depositStatus, depositCents, depositPaidCents, startAt, endAt, customerId, checkoutSessionId), outbox (message ids), webhooks (event names), result and livemode: false. |
sandbox_reset | bookings:write | sandbox (id, slug, name, timezone, currency, createdAt, lastActiveAt, expiresAt, adminUrl, storefrontUrl, apiBaseUrl) and livemode: false. |
They are in tools/list only at a sandbox, and only for a credential that may call them. Each has a title and every MCP annotation: sandbox_outbox and sandbox_payments are read-only and idempotent, each with an outputSchema its structuredContent fits; sandbox_simulate and sandbox_reset are destructive and not idempotent, and sandbox_simulate is open-world, since a sandbox's webhooks reach your endpoints.
A refusal the agent can act on is a tool error: for a client that sends a 2025 MCP-Protocol-Version header, a result with isError: true and error.code (VALIDATION_FAILED with fieldErrors, APPOINTMENT_NOT_FOUND, NO_OPEN_CHECKOUT, SIMULATION_REFUSED, or UNKNOWN_SIMULATION with the six events); for one that sends none, the JSON-RPC error -32602 with the code in error.data. A missing scope is 403 naming missingScope, and a spent budget 429 with retryAfterSec.
Every other tool works at a sandbox as at a live business. A successful result says livemode in its structuredContent too, false here, and the text block says what it always said; a refusal (isError: true) has no livemode in its structuredContent, so read it there from result._meta["com.bookatu/livemode"] or the Bookatu-Livemode header.
Limits
- A test key is held to 25 calls a minute and 1,000 a day, the sandbox's keys together. Every answer carries
RateLimit-Limit,RateLimit-Remaining,RateLimit-ResetandRateLimit-Policy, and a429carriesRetry-After. sandbox_outbox: 30 calls a minute per sandbox, and 30 a minute per key, shared with GET /sandbox/outbox. The outbox keeps the newest 500 messages.sandbox_payments: 30 calls a minute per sandbox, and 30 a minute per key, shared with GET /sandbox/payments.sandbox_simulate: 10 calls a minute and 500 a day per sandbox, and 10 a minute per key, shared with POST /sandbox/simulate/{event}.sandbox_reset: 30 resets a day per address, shared with POST /api/developer/sandboxes/{id}/reset. Only the sandbox's ownbk_test_key withbookings:write, never a connected app or a live key.- The sandbox tools reach only the sandbox the credential belongs to: a booking of any other business is
APPOINTMENT_NOT_FOUND. A live business never lists them, and called there, each is an unknown tool. - Messages are kept, never sent, and cards never charged, so a real delivery can't be tested: read the outbox instead. Webhooks are the one thing a sandbox sends, to your own endpoints, with
livemode: false. - A sandbox has no Twilio, WhatsApp, Google Calendar or Stripe connection, and memberships can't be bought in test mode yet. The rest is in Testing with a sandbox.
When the agent works, see Going live: the same tools at a live business, with its live key, and no sandbox tools.