Skip to main content
Glama
bookings-mcp

bookings-mcp

by EltonC369

bookings-mcp

CI

Replace EltonC369 in the badge URL after forking/cloning this repository.

An MCP (Model Context Protocol) server that demonstrates production-grade safety patterns for giving AI agents write access to real systems: strict read/write tool separation and approval-gated writes. Every mutating tool requires a one-time approval token that is cryptographically bound to the exact arguments a human signed off on — change one field after approval and the token is dead.

The domain is a small booking system (customers, bookings, availability) with fictional seed data. The point is not the bookings — it is the pattern for letting an agent read freely while writes stay under human control, enforced in host code rather than in the prompt.

Table of contents

Related MCP server: OpenTool

Key features

  • Read/write tool separation — read tools are annotated with readOnlyHint: true; write tools make no such claim and cannot mutate without passing the gate.

  • Approval-gated writes — create_booking and cancel_booking require an { token, approvedBy } payload. Without it, the call fails schema validation; with a forged or stale token, it is blocked by the ApprovalStore.

  • Args-bound tokens — each token is bound to SHA-256(tool name + canonicalized args) with sorted keys (the approval object itself excluded). Any post-approval change to the action invalidates the token.

  • Single-use, 10-minute TTL — tokens are consumed on first use, even when the call fails, so they cannot be replayed or used to probe the gate.

  • Audit trail — every tool call (read, write, or approval) is recorded with timestamp, kind, outcome, argument preview, and the reason for any block.

  • Two transports — stdio for local MCP hosts (Claude Desktop, Claude Code) and stateless Streamable HTTP with optional bearer-token auth for service deployments.

  • Tested end to end — 19 tests drive a real MCP client against the server through in-memory transports, covering the gate from every angle.

Tech stack

Layer

Choice

Language

TypeScript (ESM, strict: true)

Runtime

Node.js >= 20

MCP SDK

@modelcontextprotocol/sdk ^1.32.1

Validation

zod ^4

HTTP transport

express ^5

Tests

vitest ^4 (MCP client over InMemoryTransport)

Build

tsc — dev loop with tsx

Prerequisites

  • Node.js 20 or newer

  • npm (the repo ships a package-lock.json, so npm ci works)

Getting started

# 1. install
npm ci                # or: npm install

# 2. verify — 19 tests should pass
npm test

# 3. build
npm run build

# 4a. run over stdio (default transport)
npm start             # node dist/index.js

# 4b. or run over Streamable HTTP during development
npm run dev:http      # tsx src/index.ts --http --port 3270

# 4c. run HTTP with a bearer token (recommended before any exposure)
BOOKINGS_MCP_TOKEN="a-long-random-secret" npm run dev:http

Useful scripts:

Command

What it does

npm run build

Compile src/ to dist/ via tsc

npm start

Run the compiled server over stdio

npm run dev

Run src/index.ts directly over stdio (tsx)

npm run dev:http

Run over Streamable HTTP on port 3270 (tsx)

npm test

Run the vitest suite once

npm run test:watch

Vitest in watch mode

npm run typecheck

tsc --noEmit

The server prints its startup banner to stderr (stdout is reserved for the stdio protocol).

Connecting an MCP client

stdio (Claude Desktop / Claude Code)

Add to your MCP client configuration (e.g. claude_desktop_config.json):

{
  "mcpServers": {
    "bookings": {
      "command": "node",
      "args": ["/absolute/path/to/bookings-mcp/dist/index.js"]
    }
  }
}

No flags are needed — stdio is the default transport. The 7 tools appear as list_customers, get_customer, list_bookings, check_availability, request_approval, create_booking, cancel_booking.

Streamable HTTP (curl)

Start the server with npm run dev:http (optionally with BOOKINGS_MCP_TOKEN), then speak JSON-RPC to POST /mcp. The transport is stateless — no session id is issued, and GET/DELETE /mcp return 405.

BASE=http://127.0.0.1:3270/mcp
# add when a token is configured: -H "Authorization: Bearer $BOOKINGS_MCP_TOKEN"

# initialize
curl -s "$BASE" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2025-03-26",
        "capabilities":{},
        "clientInfo":{"name":"curl","version":"0.0.0"}}}'

# list tools
curl -s "$BASE" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

# call a read tool
curl -s "$BASE" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
        "name":"check_availability",
        "arguments":{"date":"2026-11-02","times":["09:00","11:00","16:30"]}}}'

Tools

Tool

Kind

Requires approval

Description

list_customers

read

—

List all customers

get_customer

read

—

Fetch a single customer by id

list_bookings

read

—

List all bookings with their status

check_availability

read

—

Check which of the given 30-minute slots are free on a date (YYYY-MM-DD, 1–16 times per call)

request_approval

approval

mints them

Queue a write action for approval; returns a one-time token bound to the exact tool + args. Never executes anything.

create_booking

write

✅ { token, approvedBy }

Create a booking for an existing customer in a free slot

cancel_booking

write

✅ { token, approvedBy }

Cancel an existing booking by bookingId

Business rules enforced by the store: slots run 09:00–16:30 in 30-minute steps (:00/:30 only), dates are YYYY-MM-DD, double-booking a taken slot is rejected, and cancelling twice fails. The store is seeded with 3 fictional customers and 3 confirmed bookings (dates relative to server start).

The approval flow

flowchart TD
    A["Agent wants to write"] -->|"request_approval { tool, args }"| B["Token minted:<br/>SHA-256(tool + canonical args)"]
    B --> C{"Human reviews the<br/>EXACT action"}
    C -->|"rejected"| D["No valid token —<br/>the write has no execution path"]
    C -->|"approved"| E["One-time token,<br/>10-minute TTL"]
    E -->|"create_booking / cancel_booking<br/>with UNCHANGED args + { token, approvedBy }"| F{"ApprovalStore.consume()<br/>tool match · args hash · TTL · single-use"}
    F -->|"any check fails"| G["BLOCKED —<br/>token consumed, reason returned"]
    F -->|"all pass"| H["Store mutation executes"]
    G --> I["Audit entry (ok: false + reason)"]
    H --> J["Audit entry (ok: true)"]

Demo note: in this repository request_approval immediately returns the token, standing in for the human surface. The gate mechanics — binding, single-use, TTL, audit — are the production-grade part; see Production hardening notes for wiring a real approval surface.

A full cycle, end to end. The tool results below show the JSON payload delivered in the tool's text content block.

1. The agent requests approval for the exact action:

{
  "jsonrpc": "2.0", "id": 10, "method": "tools/call",
  "params": {
    "name": "request_approval",
    "arguments": {
      "tool": "create_booking",
      "args": {
        "customerId": 1,
        "date": "2026-11-02",
        "time": "11:00",
        "service": "consultation"
      },
      "requester_note": "Ada Miranda asked for Monday 11:00"
    }
  }
}

2. A token comes back, bound to that action:

{
  "message": "Approval requested. A human must review and release this token.",
  "approvalId": "9f3c81a2",
  "token": "9f3c81a2c47d1e6b0a5f8d2e4c6a9b1d3e7f5a0c8b2d4e6f...",
  "boundTo": { "tool": "create_booking", "args": { "customerId": 1, "date": "2026-11-02", "time": "11:00", "service": "consultation" } },
  "expiresAt": "2026-11-01T09:12:04.511Z",
  "note": "Pass { token, approvedBy } in the write tool. Any change to the arguments invalidates the token."
}

3. A human releases the token, and the agent calls the write tool with unchanged arguments:

{
  "jsonrpc": "2.0", "id": 11, "method": "tools/call",
  "params": {
    "name": "create_booking",
    "arguments": {
      "customerId": 1,
      "date": "2026-11-02",
      "time": "11:00",
      "service": "consultation",
      "approval": { "token": "9f3c81a2c47d1e6b...", "approvedBy": "elton" }
    }
  }
}

4. The gate validates and the booking is created (returning the new booking object).

If step 3 had sent "time": "12:00" instead, the response would be:

{ "isError": true, "content": [{ "type": "text", "text": "BLOCKED BY APPROVAL GATE: Arguments changed after approval — the token is bound to the original action" }] }

…and the consumed token could not be retried, even with the original arguments.

Security model

  1. Enforcement lives in host code, not in the prompt. Write tools take approval as a required, schema-validated argument and route through ApprovalStore.consume() before touching the store. Why it matters: the model cannot talk its way around a check it has no ability to bypass — there is no code path from a write tool to the store's mutators that skips the gate.

  2. Approvals are bound to the exact action. The token records SHA-256(tool name + canonicalized JSON of the arguments) — object keys sorted, the approval object itself excluded from the hash. Why it matters: an agent (or an attacker) cannot get a harmless action approved and then quietly swap in different arguments; changing a single field invalidates the token.

  3. Tokens are single-use — even on failure. consume() deletes the token before validating tool match, expiry, and hash. Why it matters: a replayed approval can never double-apply, and a blocked call cannot be retried as a probing loop against the gate.

  4. Approvals expire in 10 minutes (APPROVAL_TTL_MS). Why it matters: a leaked or lingering token has a short window of usefulness, forcing fresh human sign-off for anything delayed.

  5. Every call is audited. Each tool invocation records timestamp, tool, kind (read / write / approval), success, an argument preview, and — for blocked writes — the exact gate reason. Why it matters: blocked attempts are first-class evidence, not silent failures, so you can answer "what did the agent try?" after the fact.

  6. The transport is authenticated before the gate is even reached. Over HTTP, setting BOOKINGS_MCP_TOKEN requires Authorization: Bearer <token> on every request; without a token the server accepts localhost clients only and logs a loud warning (it binds to 127.0.0.1). Why it matters: the approval gate protects the store from the model — auth protects the server from everyone else.

Project structure

bookings-mcp/
├── src/
│   ├── index.ts            # entry: stdio by default; --http for Streamable HTTP; --port <n> (default 3270)
│   ├── server.ts           # buildServer() factory: McpServer + tool registration (injectable store/approvals/audit)
│   ├── transport/
│   │   └── http.ts         # stateless Streamable HTTP on POST /mcp; optional bearer auth via BOOKINGS_MCP_TOKEN
│   ├── store/
│   │   ├── types.ts        # Customer, Booking, Availability domain types
│   │   └── memory-store.ts # in-memory BookingStore, fictional seed data, 09:00–16:30 / 30-min slot rules
│   ├── policy/
│   │   ├── approvals.ts    # ApprovalStore: SHA-256(tool + canonicalized args), single-use, 10-min TTL
│   │   └── audit.ts        # AuditLog: one structured entry per tool call
│   └── tools/
│       ├── read-tools.ts   # list_customers, get_customer, list_bookings, check_availability (readOnlyHint: true)
│       └── write-tools.ts  # request_approval, create_booking, cancel_booking (writes demand a valid approval)
├── tests/
│   ├── read-write-separation.test.ts  # readOnly annotations honored; writes without/forged approval blocked
│   ├── approval-flow.test.ts          # happy path, tampered args, cross-tool replay, single-use, double-booking
│   └── audit.test.ts                  # every call audited; blocks recorded with the gate reason
├── package.json
└── tsconfig.json

Testing

npm test           # 3 files, 19 tests (as reported by vitest)

The suite does not mock the server internals — each test connects a real MCP Client to the built server through InMemoryTransport and drives it exactly like a host would:

  • read-write-separation.test.ts — the four read tools carry readOnlyHint: true and the write tools do not; reads succeed with no approval; create_booking without an approval argument fails schema validation; a forged 48-char token is rejected with Unknown approval token.

  • approval-flow.test.ts — the happy path books a slot with an exact-args approval; changing the time after approval invalidates the token; a create_booking approval cannot be replayed against cancel_booking; a second use of the same token fails; and the business rule (no double-booking) still applies after the gate passes.

  • audit.test.ts — a read → approval → write sequence produces audit entries of all three kinds with correct outcomes, and blocked writes are recorded as failures carrying the gate's reason.

Together they pin the guarantee from both sides: writes cannot happen without a valid, exact-match, unconsumed approval — and every attempt, successful or blocked, leaves a trail.

Production hardening notes

This is a demonstration of the pattern, with deliberate simplifications. To take it to production:

  • Persistence — swap BookingStore for Postgres/SQLite behind the same small interface (buildServer() accepts injected deps, so no tool code changes).

  • Real human surface — replace the demo minting in request_approval with an actual approval surface: a dashboard, a Slack "Approve/Deny" button, or a mobile tap that calls ApprovalStore.request(). The gate itself needs no changes.

  • Shared state over HTTP — stateless mode builds a fresh server per request; pass shared { store, approvals, audit } instances into startHttpServer(port, deps) (or externalize them to Redis/Postgres) before scaling horizontally.

  • Auth & TLS — always set BOOKINGS_MCP_TOKEN when anything other than localhost needs access; terminate TLS at a reverse proxy in front of the server.

  • Audit sink — ship AuditLog entries to an append-only destination (file, DB table, SIEM) instead of stderr.

License

MIT — Copyright (c) 2026 Elton Castro

Available Tools

7 tools
cancel_bookingCancel a booking (approval required)A

Cancel an existing booking by id. REQUIRES a prior approval from request_approval bound to these exact arguments.

Inputs: approval (required), bookingId (required).

check_availabilityCheck availabilityB
Read-only

Check which of the given 30-minute timeslots are available on a date (YYYY-MM-DD). Read-only.

Inputs: date (required), times (required).

create_bookingCreate a booking (approval required)A

Create a booking for an existing customer in a free 30-minute slot. REQUIRES a prior approval from request_approval bound to these exact arguments.

Inputs: date (required), time (required), service (required), approval (required), +1 more.

get_customerGet customerC
Read-only

Fetch a single customer by id. Read-only.

Inputs: id (required).

list_bookingsList bookingsB
Read-only

List all bookings with their status. Read-only.

Inputs: no named parameters.

list_customersList customersB
Read-only

List all customers. Read-only.

Inputs: no named parameters.

request_approvalRequest human approval for a write actionA

Queue a write action (create_booking or cancel_booking) for human approval. Returns a one-time approval token bound to the EXACT arguments. The token must be passed back, unchanged, in the write tool's approval argument. This tool only mints approvals — it never executes the action itself.

Inputs: args (required), tool (required), requester_note.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.7/5.0

    Scored across 7 tools

    Disambiguation5/5

    Each tool has a clearly distinct purpose: customer reads (list/get), booking reads (list), availability checking, an approval-minting tool, and two write tools. The request_approval tool is explicitly scoped to only mint tokens and never execute, cleanly separating it from create_booking/cancel_booking. No meaningful overlap exists.

    Naming Consistency5/5

    All seven tools follow a consistent snake_case verb_noun pattern (list_customers, get_customer, check_availability, request_approval, create_booking, cancel_booking). The convention is uniform throughout with no deviations.

    Tool Count5/5

    Seven tools is well within the ideal range and each earns its place: four read tools, one approval tool, and two write tools. Nothing feels redundant or missing at the count level.

    Completeness4/5

    The core booking lifecycle (check availability, approve, create, cancel) plus customer lookup is covered. Minor gaps exist, such as no get_booking by id and no reschedule/update booking operation, but agents can work around these via list_bookings and cancel+recreate.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • -
      license
      Not graded
      quality
      B
      maintenance
      Enables AI agents to discover and invoke expert booking tools via a Streamable HTTP MCP server, supporting slot search, holds, payments, and human-in-the-loop confirmations with strict validation and time-based contracts.
      -
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI agents to call backend tools under per-agent policy, deciding which tools each agent can see, what arguments it may pass, how often it may call, and which writes wait for human approval. It also redacts secrets and PII from tool results, fences untrusted output against injection, and records every call in a hash-chained audit log.
      19 npm
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables AI assistants to safely interact with operations systems such as cloud inventory, runbooks, and ticketing while enforcing role-based access, JWT authentication, write confirmation, secret and PII redaction, rate limits, and audit logging.
      MIT