Server data from the Official MCP Registry
Calendars for AI agents: free/busy, next free slot, and bookings PostgreSQL never double-books.
About
Calendars for AI agents: free/busy, next free slot, and bookings PostgreSQL never double-books.
Security Report
Valid MCP server (2 strong, 0 medium validity signals). No known CVEs in dependencies. Imported from the Official MCP Registry. Trust signals: 4 highly-trusted packages.
12 files analyzed · No issues found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
What You'll Need
Set these up before or after installing:
Environment variable: DATABASE_URL
Environment variable: SLOTLOCK_AUTH_TOKEN
Environment variable: SLOTLOCK_CONFIRMATION_SECRET
Environment variable: SLOTLOCK_METRICS_TOKEN
Environment variable: PORT
Environment variable: SLOTLOCK_MIGRATE_DATABASE_URL
Environment variable: SLOTLOCK_CONFIRM_WRITES
Environment variable: SLOTLOCK_AVAILABILITY
Environment variable: SLOTLOCK_REST_API
Environment variable: SLOTLOCK_RETENTION_DAYS
Environment variable: SLOTLOCK_SHARED_LIMITS
Environment variable: SLOTLOCK_WEBHOOKS
Environment variable: SLOTLOCK_TENANT
How to Install
Add this to your MCP configuration file:
{
"mcpServers": {
"io-github-pilotaai-slotlock": {
"env": {
"PORT": "your-port-here",
"DATABASE_URL": "your-database-url-here",
"SLOTLOCK_TENANT": "your-slotlock-tenant-here",
"SLOTLOCK_REST_API": "your-slotlock-rest-api-here",
"SLOTLOCK_AUTH_TOKEN": "your-slotlock-auth-token-here",
"SLOTLOCK_AVAILABILITY": "your-slotlock-availability-here",
"SLOTLOCK_CONFIRM_WRITES": "your-slotlock-confirm-writes-here",
"SLOTLOCK_CONFIRMATION_SECRET": "your-slotlock-confirmation-secret-here",
"SLOTLOCK_MIGRATE_DATABASE_URL": "your-slotlock-migrate-database-url-here"
},
"args": [
"-y",
"slotlock"
],
"command": "npx"
}
}
}Documentation
View on GitHubFrom the project's GitHub README.
Slotlock
Slotlock is a calendar for AI agents. It books vehicles, rooms, machines, people or anything else reservable, answers "when is this free?" from weekly availability rules, and lets PostgreSQL reject double bookings, however many writers race. Agents call it over MCP or A2A; your code calls it as a TypeScript library.
Why it is built for agents
- Resources own calendars, not users, and the tenant comes only from the authenticated caller.
- Intervals are half-open
[start, end), so back-to-back bookings never collide. - A PostgreSQL exclusion constraint decides overlaps; a conflict comes back as data, not an error.
- Writes are idempotent and take an expected revision.
- Free/busy says whether it is certain: a calendar not yet synced makes time unproven, not free.
- Tool names, schemas and errors work on Claude, OpenAI and every MCP host.
Install
npm install slotlock postgres
Node.js 22.12+, PostgreSQL 16 or newer with btree_gist (created for you if missing; CI runs 16,
17 and 18), and postgres ^3.4.5 as a peer dependency. TypeScript declarations are included.
Self-host with Docker
From a clone of this repository:
scripts/quickstart-env.sh > .env # .env.example, its four secrets set to openssl rand -hex 32
docker compose up --build --detach
docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/London
| URL | What |
|---|---|
http://localhost:8080/mcp | MCP, with Authorization: Bearer <SLOTLOCK_AUTH_TOKEN> |
http://localhost:8080/a2a | A2A 1.0, same token; card at /.well-known/agent-card.json |
http://localhost:8080/v1/… | REST: the tools, and the application API for admin keys; described at /openapi.json |
http://localhost:8080/healthz | Readiness, including the database |
http://localhost:8080/metrics | Prometheus metrics, with Authorization: Bearer <SLOTLOCK_METRICS_TOKEN>; off without it |
- Metrics count calls by protocol, operation and outcome (
slotlock_calls_total, with their duration inslotlock_call_duration_seconds), confirmations, subscriptions, refusals, maintenance and webhook deliveries. No label names a tenant, key or client.createSlotlockMetricsbuilds them for a server of your own, from its hooks. - The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
migrateservice applies the schema as the owner. SLOTLOCK_AVAILABILITYsets the default bookable hours. A resource can have its own (slotlock hours set, or the dashboard); with neither, no slot is offered.SLOTLOCK_CONFIRM_WRITES=all(as.env.examplesets it; the server's default isnone) makes every write wait for a person.- To serve beyond this machine, put a TLS proxy in front and set
SLOTLOCK_PUBLIC_URL. - The server limits requests per client address (before any key is looked up), per key, per
tenant and for the whole server, and answers
429 rate_limitedwithRetry-Afterand theRateLimitfields. The application API counts an admin key's calls in its tenant's ownapp_readandapp_writebudgets, apart from the agents'; another key's are its own. A recurring create and every event update (a new title re-expands a recurring event's series too) takes its write token, then waits for a slot: one at a time per tenant (tenant_series_writes) and per server (series_writes); each server process counts its own. A freed slot rests for as long as the server was busy while the write before held it (at most a second), so with one slot, writes that each hold it under a second keep the server busy at most about half the time. It then goes to the waiting tenant with the fewest writes in flight and, among those, the one whose writes have kept the server busy least lately (halving every ten seconds), then to the first to ask. So a tenant sending one write at a time waits for the write running, its rest, and the other tenants with one write in flight whose writes kept the server less busy: many such tenants, new ones included, can each go ahead of it once. Tenants keeping more writes waiting never go ahead of it. Four more of a tenant's writes may wait and the rest get 429. When the server's 64 places are all taken, a write from a tenant with fewer writes in flight than the tenant with the most takes the place of that tenant's last write, which gets 503server_busy; otherwise it gets 503 at once. An expansion runs on the server's main thread, so any request may also wait for the one running. A write that has waited two seconds gets 429 while its tenant's own write is still running, otherwise 503server_busy. An application API maintenance run waits the same way for slots of its own: one per tenant,maintenance_runsper server. The health check is not counted. One tenant's requests hold at mosttenant_transactions(2) of the server's 10 database connections at once and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to other tenants and key lookups; a server with one tenant can settenant_transactions=10. A request whose transaction waits more than 5 seconds for a lock gets 503server_busy. With more than one server,SLOTLOCK_SHARED_LIMITS=onshares the tenant budgets (tenant_*,app_*) through the database, so N servers allow a tenant its rate once, not N times; each server checks its own buckets first, and goes on with them alone if the database cannot be reached.SLOTLOCK_LIMITSchanges the limits; behind a proxy, setSLOTLOCK_TRUSTED_PROXY_HEADERto the field it puts the client's address in (CF-Connecting-IPbehind Cloudflare) before the server takes traffic, or every client shares the proxy's budget: the server logs aclient_address_field_ignoredwarning when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then kamal-proxy, name the first one's field: the server reads the last address of a list field such asX-Forwarded-For, which the second proxy writes as Cloudflare's. A client can send that field itself, so let nothing reach the server except through the proxy. - The server also bounds what one tenant may store, whichever key, agent or application writes
it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as
the store's
tenantLimitscount them, besides 10,000 resources. A write past one answers 409hold_limit_reached,reservation_limit_reached,event_limit_reachedorcoverage_limit_reached;SLOTLOCK_LIMITSchanges them (tenant_holds,tenant_reservations,tenant_events,tenant_coverage).
slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
A rule is rrule, startMinutes and durationMinutes here and in SLOTLOCK_AVAILABILITY. The
REST application API spells every field in snake_case, so there it is rrule, start_minutes and
duration_minutes. slotlock --help lists every setting.
Booking rules
A resource can also say what a booking on it must satisfy:
slotlock rules set '{"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}' --resource vehicle-42
slotlock rules clear --resource vehicle-42
| Rule | A booking made now |
|---|---|
minNoticeMinutes | starts at least this far ahead |
maxAdvanceDays | starts at most this far ahead |
slotStepMinutes | starts on a step from local midnight in the resource's time zone |
stepOffsetMinutes | starts this far past each step (0 to the step less one): with a 30-minute step, 15 starts bookings on :15 and :45 |
bufferBeforeMinutes | keeps this long free before it: its leading buffer, which the database enforces |
turnaroundMinutes | keeps this long free after it: its trailing buffer, which the database enforces |
enforceHours | lies inside the resource's bookable hours |
checkInMinutes | is claimed (checkInReservation) this long after its start, or maintenance releases it |
requireCertain | lies in time every calendar sync recording coverage for the resource has read within a day |
- Holds, reservations, moves and agents' events are checked when they are made, by the database's
clock; a refusal names the rule (
booking_rule_violatedwithrulein the store,booking_rule_min_notice,_max_advance,_slot_step,_outside_hoursor_uncertainto an agent). UnderrequireCertain, a search (the store's and the tools') needs those syncs' coverage too, read within a day, of the slot and its turnaround, so it offers no slot they leave unproven. - A series is judged on its first occurrence for notice and advance, and on every occurrence for step and hours. Imported calendar events are not checked: they happened elsewhere.
- Searches (
slotlock_find_next_available,slotlock_find_available_slots,findNextAvailableFor) offer only slots the rules allow, so an offered slot is one the write accepts. - Two bookings stay at least the earlier one's trailing buffer plus the later one's leading buffer apart: each occupies from its start less its leading buffer to its end plus its trailing one, and the exclusion constraint keeps those apart. Free/busy reports that time as busy.
- The REST application API sets them with
PUT /v1/resources/{resource_id}/booking-rules, in snake_case; the store withsetResourceBookingRules. - Defaults: rules for every resource of the tenant (
PUT /v1/booking-rules) and for every member of a pool (PUT /v1/pools/{pool}/booking-rules), read back withGET /v1/booking-rules; the store'ssetBookingRuleDefaultsandgetBookingRuleDefaults. A resource's rules as they apply (effective_booking_rules,effectiveBookingRules) are the tenant's defaults, overlaid by its pool's, overlaid by its own: a rule set at a narrower level wins.checkInMinutesis a resource's own only. The dashboard's resource page shows which rules a resource inherits, and from where. - With no rule set anywhere, nothing changes: every rule is opt-in.
- A tenant has default rules for at most 1,000 pools (
pool_rules_limit_reached, 409, for one more). Merged rules are read leniently: a pool'sstepOffsetMinutesunder a resource's shorter step applies modulo that step, and a field that is invalid on its own is ignored (and logged), never read as refusing every booking.
Without Docker
With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as npx slotlock after
npm install slotlock postgres, or from a clone, built once, as node dist/cli.js (a clone does not
put slotlock on your PATH). Use two roles, as Compose does, so two URLs: serve connects as
DATABASE_URL, a role that owns nothing and cannot bypass row-level security, and migrate
connects as SLOTLOCK_MIGRATE_DATABASE_URL, the schema owner, and grants that role what it needs.
serve refuses to start as a superuser or a BYPASSRLS role, which row-level security does not
confine, and warns (serving_as_schema_owner) when its role owns the schema.
npm ci && npm run build
# Once, as a superuser:
# CREATE ROLE slotlock_app LOGIN PASSWORD '<app password>' NOSUPERUSER NOBYPASSRLS;
export SLOTLOCK_MIGRATE_DATABASE_URL='postgresql://<owner>:<owner password>@localhost:5432/slotlock'
export DATABASE_URL='postgresql://slotlock_app:<app password>@localhost:5432/slotlock'
export SLOTLOCK_AUTH_TOKEN="$(openssl rand -hex 32)"
export SLOTLOCK_CONFIRMATION_SECRET="$(openssl rand -hex 32)"
node dist/cli.js migrate
node dist/cli.js resource add vehicle-42 --timezone Europe/London
node dist/cli.js serve
The slotlock … commands in this README run the same way as node dist/cli.js … (with Compose,
docker compose exec slotlock slotlock …).
API keys
Give each agent its own key instead of sharing the server token:
slotlock key create "Booking agent"
slotlock key create "Availability bot" --scope read --expires-in-days 90
slotlock key create "Booking site" --scope read,write,admin
slotlock key list
slotlock key rotate <id>
slotlock key revoke <id>
- A key (
slk_…) is shown once and stored only as a SHA-256. Send it asAuthorization: Bearer. - It acts in the tenant it was created for (
SLOTLOCK_TENANT), so one server serves many tenants. readcovers the seven tools that only look;writecovers create, update and delete;admincovers the application API, which no agent needs. Default: read and write.rotatekeeps the key's id, scopes and bookings;revokestops it on its next request.- A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can verify offline.
Retiring resources and tenants
slotlock resource archive vehicle-42 # no new bookings, holds or events; history kept
slotlock resource restore vehicle-42
slotlock resource delete vehicle-42 # with its history; refused while anything on it is live
slotlock tenant status
slotlock tenant suspend --reason "chargeback review"
slotlock tenant unsuspend
slotlock tenant erase --confirm "$SLOTLOCK_TENANT"
- The resource commands take a reference or an id, and act in
SLOTLOCK_TENANT. - A suspended tenant may read and writes nothing; its keys still authenticate, but it gets no new
key or secret. Only the schema owner suspends or lifts a suspension, so
suspendandunsuspendconnect asSLOTLOCK_MIGRATE_DATABASE_URL's role when it is set. erasedeletes everything the tenant has, its API keys included, at once and for good;--confirmmust repeat the tenant. A suspension outlives it.
Dashboard
The server can also serve a dashboard where people sign in with GitHub and manage their own keys and
resources, so you can give each person API keys without running slotlock key create for them:
- On GitHub, open Settings, Developer settings, OAuth apps, New OAuth App, and set the
"Authorization callback URL" to
<SLOTLOCK_PUBLIC_URL>/dashboard/callback. Then generate a client secret on the app's page. - Set
SLOTLOCK_GITHUB_CLIENT_IDandSLOTLOCK_GITHUB_CLIENT_SECRETfrom the app,SLOTLOCK_SESSION_SECRETto 32+ random characters (openssl rand -hex 32), andSLOTLOCK_DASHBOARD_USERSto the GitHub user ids that may sign in (gh api users/<login> --jq .id), or to*for every GitHub account. - Run
slotlock serveand open<SLOTLOCK_PUBLIC_URL>/dashboard. With Compose, set the four in.env(empty, the default, leaves the dashboard off) and rundocker compose up --detach.
- Its pages: an Overview with the coming week's bookings, events and live holds across every
resource (each in its resource's time zone) and the latest changes agents made, named by key;
Resources, each with its own page for bookable hours, booking rules, pool, coming bookings and
activity; Waitlist, to see who waits and remove an entry; API keys, with the MCP and A2A
endpoints; Webhooks, to add, delete and re-enable endpoints and see which are failing; and
Settings.
Everything on them is read from the tenant's own data.
SLOTLOCK_DASHBOARD_NAMEsets what the dashboard calls the deployment (Slotlock by default). - Each person gets a tenant of their own,
github:<user id>: the keys, resources and bookings in it are theirs alone. Keys made withslotlock key createact inSLOTLOCK_TENANTinstead. - For organizations instead of people, set
SLOTLOCK_DASHBOARD_ORGSto the GitHub organization ids the dashboard serves (gh api orgs/<login> --jq .id). Each is then one tenant,github-org:<id>, that its members share: keys, resources and hours. A person signs in to the served organizations they are an active owner or member of (a billing manager is neither), switching between them when there are several; someone in none of them is refused. Every member has the same power: each can create, rotate and revoke the organization's keys, and the key list says who created each one. Membership is read at each sign-in, so a person who leaves keeps the dashboard until their session ends (12 hours at most), and keys they made work until revoked. Removing an id from the list signs its sessions out and stops its keys at once. An organization that restricts OAuth apps must approve the dashboard's app (its Settings, Third-party access), or GitHub hides the membership.SLOTLOCK_DASHBOARD_USERSstill says who may sign in at all, and a key records the person who created it (github:<user id>): erasing that person's own tenant leaves it on keys they made in an organization, which go with the organization's erasure. Only the first 100 memberships GitHub lists, and the first 20 served organizations by login, are read. - Sign-in uses
stateand PKCE, and keeps nothing from GitHub but the user id and login (and, with organizations, the served ones the person belongs to): the GitHub token is used once and dropped. It asks for no scope, orread:orgwith organizations. - A session is a signed, HttpOnly cookie that lasts 12 hours. The allowlist is checked on every request, dashboard and API alike: removing an id and restarting the server signs that person out and stops every key in their tenant at once.
- Every form carries a token bound to the session and must come from the server's own origin; the pages run no inline script, under a Content-Security-Policy that allows only their own files.
- Give the dashboard an origin of its own (
https://slotlock.example.com, not a path beside other apps): its cookies are host-wide, so any other app on the same origin could read them. - Finished sign-ins (a callback works once), signed-out sessions (a copied cookie stays out) and sent
forms (a reload does not create or rotate a key twice) are recorded in Postgres, so they hold on
every server that shares the database, behind any load balancer. Each is a SHA-256 digest, kept
until its cookie or form expires (12 hours at most); a form record also carries a digest of the
GitHub user id that sent it. The server role reaches them only through two SECURITY DEFINER
functions, and
serverefuses to start the dashboard untilmigratehas granted them. - The database's clock decides when a sign-in or session has expired, so servers whose clocks drift from it or from each other cannot let a replay or a signed-out session through. Keep clocks in sync all the same: a server more than 12 hours ahead of the database cannot sign anyone in.
- Each form carries a single-use value bound to its session. A person may have 1,000 forms recorded at once (12 hours' worth); past that the dashboard answers 429 until the oldest expire. Signing out always works.
- A person may add 100 resources. The database holds the cap, so adds racing on any number of servers stop at 100; a resource they already have can still change time zone.
- Each resource uses the server's bookable hours (
SLOTLOCK_AVAILABILITY) until its owner sets its own on the hours page: two windows a day, overnight or all day, or closed. One form sets them for every resource at once.
Assistant: operate Slotlock by chat
With SLOTLOCK_ASSISTANT_SECRET set (32+ random characters, openssl rand -hex 32), the dashboard
gains an Assistant page where every member chats with a model their organization connects, instead
of clicking. Each organization brings its own model:
- Connect it in Settings, Assistant (owners only; with personal tenants, each person): the
provider (
Anthropic Messages API, orOpenAI-compatible, which covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio), the base URL (https://api.anthropic.com, or the one ending in/v1:https://api.openai.com/v1,http://localhost:11434/v1; https, or http only onlocalhostor127.0.0.1), the model name (your-model) and the API key, which a local model may not need. As with webhooks, a URL whose host is, or resolves to, a loopback, private, link-local, shared (CGNAT), unique-local or metadata address (169.254.169.254) is refused, when it is saved and at each connection (the address the socket connects to is checked, so a name rebound to one is refused too), and the page says why. For a self-hosted model on the server's own machine or network (Ollama, vLLM), the operator setsSLOTLOCK_ASSISTANT_PRIVATE_URLS=on. On Cloudflare Workers the literal check applies and names are not resolved: a Worker's fetch reaches no private network. Changing the provider or the base URL takes the key again (decided in the database write, so racing saves cannot get past it), so the stored key is only ever sent where it was entered for, even on an origin that serves several accounts. Test connection makes one real request and checks the model answers with one correct tool call. The organization's messages, and the bookings and resources the assistant reads, are sent to the provider it configures. - The key is stored sealed with AES-256-GCM under a key derived from
SLOTLOCK_ASSISTANT_SECRET, bound to the organization, inslotlock.assistant_settings(forced row-level security, erased with the tenant). The dashboard shows only that it is set and its last four characters. Changing the secret makes stored keys unreadable: owners enter them again. - Reads run, writes wait. The server runs the tool loop, up to 8 model calls a message, with a
fixed list of the agent tools (the reads: resources, free-busy, next free time, slots, pool
availability, events; the writes: holds, bookings, pool and bundle bookings, changes and
cancellations). A read runs at once through the same operation boundary as an agent's. A write
never runs when the model asks: it appears as a proposed change the member approves (or
dismisses) with a form, and only then runs, once (its idempotency key is the dashboard's), with
the same booking rules, tenant limits and rate limits as an agent, recorded as
dashboard-assistant:github:<user id>(shown as "Assistant for @login"). The card leads with the sentence a confirmation would ask (the resource by name, the times in its zone, and for a change or cancellation the event's title, resource and time as it stands) and then shows every argument in full; a change too large to show (16 KB of arguments) is not proposed, and a conversation has at most 4 waiting. A proposal is only checked: the series-write slot and the write's rate token are taken when the approved change runs. A message's reads share a budget of 16 calls and 400 resource-days. API keys, webhooks, booking rules, pools, erasure and coverage are never offered; a tool the model names outside the list is refused. - Tool results and booking data are data to the model, never instructions, and the system prompt
says so; the tool list does not change with what the model says. A member may send 10 messages
a minute; each model call has 30 seconds and a message 90. Conversations are kept per member in
slotlock.assistant_conversationsfor 2 hours after the last message, then deleted: byserve's maintenance, which sweeps every tenant's expired conversations every quarter hour (a tenant only the dashboard serves included), byslotlock maintain, and whenever the tenant's conversations are read (so on Workers too). They are bounded in UTF-8 bytes; New conversation forgets one. On Workers, the assistant's agent calls are capped at 24 a request and bytenant_readandtenant_writeofSLOTLOCK_LIMITSin each isolate. A provider's failure is shown as a plain message, never with the key or the provider's answer. - It works as plain forms: no script is needed.
Dashboard on Cloudflare Workers
The same dashboard can run as a Cloudflare Worker at an origin of its own (workers/dashboard),
while agents keep using the server. It shows the server's MCP and A2A URLs (SLOTLOCK_SERVICE_URL),
and reaches the server's database through Hyperdrive, as the role the server serves as.
workers/dashboard/wrangler.jsonc is an example: copy it into your own deployment and replace what
it marks as yours. This repository's Dashboard workflow only checks that the Worker bundles. Deploy
from your own pipeline with your own Cloudflare token: a token that can deploy Workers can replace
this one and read its session secret, which signs in as any tenant.
- Create a Hyperdrive configuration for that role, with caching off: the dashboard reads back a
sign-in or a form it has just recorded, and a cached sign-out check would keep a copied session
working. Check it before each deploy (
wrangler hyperdrive get <id>). For a database on a private network, connect it through Workers VPC, on the Cloudflare Tunnel that reaches it. Point the VPC service at a listener that serves only Slotlock's database, such as a PgBouncer with TLS and only that database and role, rather than at the whole Postgres:cd workers/dashboard && npm ci npx wrangler vpc service create slotlock-postgres --type tcp --tunnel-id <tunnel id> \ --ipv4 <listener host> --tcp-port <listener port> --app-protocol postgresql read -rs SLOTLOCK_DB_PASSWORD # not on the command line, so not in your shell history npx wrangler hyperdrive create slotlock-dashboard --service-id <vpc service id> \ --database <database> --user <server role> --password "$SLOTLOCK_DB_PASSWORD" --caching-disabled - In your copy of the configuration, set:
- the configuration's id;
- your hostname (
routes); SLOTLOCK_PUBLIC_URL,SLOTLOCK_SERVICE_URLandSLOTLOCK_DASHBOARD_USERS(vars);- the three rate limiting bindings, each with a
namespace_idunique in your account.
- Create the GitHub OAuth app as above, with the callback URL
<SLOTLOCK_PUBLIC_URL>/dashboard/callbackon the Worker's origin. - Store the secrets on the Worker:
npx wrangler secret putforSLOTLOCK_GITHUB_CLIENT_ID,SLOTLOCK_GITHUB_CLIENT_SECRETandSLOTLOCK_SESSION_SECRET, andSLOTLOCK_ASSISTANT_SECRETfor the assistant. - Deploy:
npm ci --omit=devat the repository root (the Worker bundles Slotlock's sources), thennpx wrangler deploy --config <your configuration>inworkers/dashboard.
-
Each request opens its own database client, at most five connections, and closes it when the response is sent; Hyperdrive keeps the pool.
-
Anyone may start a sign-in, and each callback costs a database write and a request to GitHub with the app's client secret. An isolate runs at most eight at once, but Workers run many isolates, so three rate limiting bindings bound them all:
SLOTLOCK_CLIENT_RATE_LIMITcounts every dashboard request per client address (an IPv6 client's /64, asslotlock servecounts it);SLOTLOCK_SIGN_IN_RATE_LIMITcounts sign-ins and callbacks per client network (an IPv6 client's /48, a site's allocation, asslotlock servecounts its sign-ins), so a site rotating through its /64s cannot use up the ceiling;SLOTLOCK_SIGN_IN_CEILING_RATE_LIMITcounts them from every client together, set far above one client's limit so a few clients cannot use it up for everyone.
Their counts are per Cloudflare location. Without either of the first two the Worker answers 503. Without the ceiling, as in a configuration from before it,
SLOTLOCK_SIGN_IN_RATE_LIMITcounts every client together, and the Worker logs a warning: add the ceiling before lowering that binding's limit to one client's. The example configuration also removes query strings (a callback's OAuth code) from the URLs Workers Logs keep. -
With organizations, set
SLOTLOCK_DASHBOARD_ORGSon both the Worker and the server: the server takes an organization's keys only while its own list names it, whether or not it serves the dashboard. -
The server checks a dashboard tenant against
SLOTLOCK_DASHBOARD_USERSonly when it serves the dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs them out of the dashboard; to stop their keys, revoke them or erase the tenant. -
A missing or weak secret makes the Worker answer 503 and log which variable, never its value.
Connect an MCP client
Point any Streamable HTTP client at http://localhost:8080/mcp with the token.
Claude Code:
claude mcp add --transport http slotlock http://localhost:8080/mcp \
--header "Authorization: Bearer $SLOTLOCK_AUTH_TOKEN"
Cursor (~/.cursor/mcp.json):
{
"mcpServers": {
"slotlock": {
"url": "http://localhost:8080/mcp",
"headers": { "Authorization": "Bearer ${env:SLOTLOCK_AUTH_TOKEN}" }
}
}
}
VS Code (.vscode/mcp.json; it asks for the token once):
{
"inputs": [
{ "type": "promptString", "id": "slotlock-token", "description": "Slotlock token", "password": true }
],
"servers": {
"slotlock": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": { "Authorization": "Bearer ${input:slotlock-token}" }
}
}
}
With SLOTLOCK_CONFIRM_WRITES=all, writes need a client that can show a confirmation (MCP
2026-07-28 with form elicitation); other clients get confirmation_required. Set it to none to let
agents book on their own.
Quick start: the next free slot
The availability maths needs no database:
import { expandRules, findNextAvailable } from 'slotlock';
const searchWindow = {
start: new Date('2026-09-14T00:00:00Z'),
end: new Date('2026-09-21T00:00:00Z'),
};
const windows = expandRules(
[{ rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 8 * 60 }],
searchWindow,
'Europe/London',
);
export const slot = findNextAvailable({
windows,
busy: [{ start: new Date('2026-09-14T09:00:00Z'), end: new Date('2026-09-14T10:00:00Z') }],
durationMs: 2 * 60 * 60 * 1000,
});
// slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
A rule expandRules cannot evaluate adds no availability; it never invents a slot. A search window
may be 367 days at most (invalid_window), here and in findNextAvailableFor.
Set up PostgreSQL
| Role | Owns | Used for |
|---|---|---|
| Deployment | the slotlock schema | applySchema, applyTenantRls, grantApplicationRole, once per release |
| Application | nothing (NOSUPERUSER NOBYPASSRLS) | all traffic, confined to one tenant by forced row-level security |
Create the application role once (CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;), then deploy as the owner:
import { createSlotlockStore } from 'slotlock';
import postgres from 'postgres';
export async function deploySlotlock(deployUrl: string, applicationRole: string): Promise<void> {
const sql = postgres(deployUrl, { max: 1, onnotice: () => {} });
try {
const store = createSlotlockStore(sql);
await store.applySchema();
await store.applyTenantRls();
await store.grantApplicationRole(applicationRole);
} finally {
await sql.end();
}
}
applySchemais idempotent and safe to run from several instances at once.grantApplicationRolegrants schema usage and DML on Slotlock's tables, and refuses (unsafe_application_role) a role that could get around row-level security. Run it after everyapplySchema. SECURITY.md lists what it checks.applySchemarefuses aslotlockschema another role controls (unsafe_slotlock_schema).- Already have a tenant setting such as
app.tenant_id? Pass{ tenantContextSetting: 'app.tenant_id' }tocreateSlotlockStore.
Book and read as the application
import { type SlotlockStore, createSlotlockStore } from 'slotlock';
import postgres from 'postgres';
export function openSlotlock(applicationUrl: string) {
const sql = postgres(applicationUrl, { max: 10 });
return { sql, store: createSlotlockStore(sql) };
}
export async function bookHandover(store: SlotlockStore, tenantRef: string) {
return store.withTenant(tenantRef, async (tenant) => {
const vehicle = await tenant.createResource({
tenantRef,
externalRef: 'vehicle-42',
timezone: 'Europe/London',
});
const handover = await tenant.putCalendarEvent({
tenantRef,
externalRef: 'handover-BK-42',
idempotencyKey: 'booking-BK-42-create',
expectedRevision: 0,
resourceId: vehicle.id,
start: new Date('2027-03-29T09:00:00Z'),
end: new Date('2027-03-29T10:00:00Z'),
timezone: 'Europe/London',
summary: 'Vehicle handover',
organizer: { email: 'fleet@example.com', name: 'Fleet Desk' },
attendees: [{ email: 'renter@example.com', participationStatus: 'needs_action', rsvp: true }],
reminders: [{ action: 'display', minutesBeforeStart: 30 }],
});
if (!handover.ok) throw new Error(`handover not booked: ${handover.code}`);
const freeBusy = await tenant.getFreeBusy({
tenantRef,
resourceId: vehicle.id,
window: { start: new Date('2027-03-29T00:00:00Z'), end: new Date('2027-03-30T00:00:00Z') },
});
return { vehicle, handover, freeBusy };
});
}
Run every store method inside withTenant. A call outside it is not refused: it runs with no tenant
context, so as the application role forced row-level security shows it no rows.
withTenant validates the tenant, sets the context transaction-locally on the one connection every
callback call uses, supports nested savepoints, and restores the previous context. Do not issue a
standalone set_config(..., true) through a pool: its transaction ends before the next call.
withTenant(tenantRef, callback, { isolation: 'read committed' }) runs the callback in a READ
COMMITTED transaction whatever the role's default, and refuses inside a caller's transaction of
another level. A store sharing one pool among tenants can bound each tenant's share:
createSlotlockStore(sql, { maxTenantTransactions, tenantLockTimeoutMs }) runs at most that many
of a tenant's withTenant transactions at once (the rest wait in the process, and leave the line
when withTenant's signal aborts), and refuses one that waits longer than that for a lock with
lock_timeout, safe to retry. slotlock serve sets tenant_transactions (2) and 5,000.
createResource({ ..., maxTenantResources }) refuses a new resource (resource_limit_reached) once
the tenant has that many (1-100,000); an existing externalRef is still updated. Capped creates for
one tenant take a database lock and count under READ COMMITTED, so racing ones stop at the cap on
any number of servers; inside a REPEATABLE READ or SERIALIZABLE transaction they refuse
(invalid_transaction_isolation). Uncapped creates take no lock.
setResourceAvailability({ tenantRef, id, rules }) gives a resource its own bookable hours (null
uses the default, [] closes it); setTenantAvailability({ tenantRef, rules }) sets every resource
of a tenant. createSlotlockStoreAgentBackend answers from a resource's own hours and calls
availabilityRules only for resources without them.
Waitlists: joinWaitlist({ tenantRef, resourceId | pool, start, end, holderRef, offerTtlMs })
waits for a window that is taken now. When it comes free (a cancel, a released or expired hold, a
no-show), offerWaitlist gives it to the entries in the order they joined: the first whose window
is free, on its resource or the first free member of its pool, gets a hold for its holder, and the
feed says so (waitlist.offered, with hold_id). Confirm the hold to book it; let it go and the next
is offered. Cancels, releases and moves, whether an agent (MCP, A2A) or the application API makes
them, offer at once; maintenance offers what lapses on its own (an expired hold, a no-show) and
expires entries whose window has started (waitlist.expired). With checkInMinutes in a
resource's booking rules, maintenance releases a booking made since the rule was set that nobody
checked in that long after its start, or after it was made for a walk-in (reservation.no_show),
and the waitlist is offered its time. Check-in opens an hour before the start; moving a booking to
another start clears it. Leaving the waitlist lets an offer not yet confirmed go with it.
Calendar tools speak the standards: emitVAvailability({ rules, timezone, uid }) writes rules as an
iCalendar VAVAILABILITY (RFC 7953) and parseVAvailability(text) reads one back as
{ timezone, rules }; toJSCalendarEvent(event) answers a calendar event as a JSCalendar Event
(RFC 8984). The API serves them at /v1/resources/{resource_id}/availability/ical and
/v1/tenant/events/{event_id}/jscalendar.
Writing rules:
- Create with
expectedRevision: 0; update with the revision you last read and a new idempotency key. Replaying the same command and key returns the original result (idempotent: true); reusing a key with a different payload returnsidempotency_conflict. - A conflicting opaque occurrence returns
{ ok: false, code: 'overlap' }. Transparent events never block. - Cancelling leaves a tombstone, so a delayed create cannot resurrect the event; use a new external reference for a genuinely new event.
listCalendarEventsandlistResourcesreturn at most 1,000 rows and page with keyset cursors.
Holds, external bookings and the feed
export async function holdBookAndFollow(store: SlotlockStore, tenantRef: string, resourceId: string) {
// Inside withTenant: outside it the application role's row-level security shows no rows.
const booked = await store.withTenant(tenantRef, async (tenant) => {
// Hold a window for ten minutes while the customer pays, then turn it into a booking.
const held = await tenant.acquireHold({
tenantRef,
resourceId,
start: new Date('2027-04-05T09:00:00Z'),
end: new Date('2027-04-05T10:00:00Z'),
ttlMs: 10 * 60_000,
});
if (!held.ok) throw new Error(`not held: ${held.code}`);
const confirmed = await tenant.confirmHold(held.hold.id, { tenantRef });
if (!confirmed.ok) throw new Error(`hold not confirmed: ${confirmed.code}`);
// A booking your own system already has, by its reference there: revision 1 creates it.
const imported = await tenant.createExternalReservation({
tenantRef,
externalRef: 'BK-43',
resourceId,
start: new Date('2027-04-05T11:00:00Z'),
end: new Date('2027-04-05T12:00:00Z'),
bufferAfterMs: 15 * 60_000,
revision: 1,
});
if (!imported.ok) throw new Error(`BK-43 not booked: ${imported.code}`);
return { booking: confirmed.reservation, imported: imported.reservation };
});
// What changed, oldest first, once committed: so in a withTenant of its own. Poll again with
// `after: feed.next`.
const feed = await store.withTenant(tenantRef, (tenant) =>
tenant.listChanges({ tenantRef, limit: 100 }),
);
if (!feed.ok) throw new Error(`feed not read: ${feed.code}`);
return { ...booked, feed };
}
Recurring events
Documentation truncated — see the full README on GitHub.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Rea
Freeby Morluto · Developer Tools
Reverse engineer anything from your terminal or agent with one CLI and MCP server.
Paperclip
Freeby Paperclipai · Developer Tools
Trending hip-hop artist momentum scores across four cultural dimensions.
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
