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, and sandbox_payments lists every test payment with what became of it.
  • Every answer says it is a test: the Bookatu-Livemode header is false on every answer, and result._meta["com.bookatu/livemode"] is false on every tool result, a refusal included. A successful result's structuredContent says livemode: false too.
  • Every run can start from the same place. sandbox_reset puts 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:

  1. Find a free time: "When could I book a haircut on Tuesday afternoon?" The agent calls list_services and get_availability (and get_business for the opening hours), and answers with times from the slots, on the business's clock.
  2. Book it: "Book it for Mia, mia@example.com, +1 613 555 0142." create_booking answers the booking's ref and appointmentId. A sandbox's clients are made up: use an address at example.com and a number in a range kept for fiction.
  3. Check the confirmation: "What was she sent?" sandbox_outbox shows the message that would have gone out (its kind is booking_confirmation), with its subject, its text and the booking's appointmentId.
  4. Simulate the deposit paid: "The client has paid the deposit." sandbox_simulate with deposit.paid pays the booking's open test checkout: the deposit is paid, the answer lists the outbox messages and webhooks it caused, and sandbox_payments shows 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}/appointments with requestDeposit: true). create_booking books 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 answers DEPOSIT_NEEDS_BOOKING_PAGE with bookingPageUrl and books nothing, as at a live business, so open that link to book it on the booking page and reach the test checkout there.
  5. Cancel: "Cancel Mia's booking." cancel_booking cancels it as the business and says whether a deposit was kept. A cancellation by the business sends the client no message, so sandbox_outbox has no message to her about it: if she should hear, the agent or the business tells her. sandbox_simulate with booking.client_cancelled is 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.
  6. 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

ToolScopeReturns
sandbox_outboxcustomers:readmessages (newest first: id, channel, kind, to, subject, text, html, appointmentId, customerId, createdAt, meta), nextCursor and livemode: false.
sandbox_paymentsreports:readpayments (newest first: id, kind, outcome, amountCents, refundedCents, currency, description, appointmentId, belongsTo, checkoutSessionId, paymentIntentId, createdAt, updatedAt), nextCursor and livemode: false.
sandbox_simulatebookings:writeok, event, appointment (id, status, depositStatus, depositCents, depositPaidCents, startAt, endAt, customerId, checkoutSessionId), outbox (message ids), webhooks (event names), result and livemode: false.
sandbox_resetbookings:writesandbox (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-Reset and RateLimit-Policy, and a 429 carries Retry-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 own bk_test_ key with bookings: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.