This is the original build spec an agent worked through to write v1 of this server, kept as a reference for the API facts it documents and as context for anyone (human or agent) extending the codebase later. See
AGENTS.mdfor current contribution conventions.
An MCP server exposing the Luma (lu.ma) Events API to Claude Code and other MCP clients.
Read this whole file before writing code. Work phase by phase. Each phase ends with a Checkpoint — do not start the next phase until the checkpoint passes.
Luma ships no official MCP server. The three community servers that exist
(adelaidasofia/luma-mcp, montaguegabe/luma-events-mcp, alx1p/luma-cal-mcp) are all Python and
all cover a small slice of the API. The real API has 68 endpoints. This project covers the core
event-operations slice properly in TypeScript.
Stack: TypeScript + Bun. Transport: stdio. Scope for v1: 18 tools (8 read, 10 write).
| Fact | Value |
|---|---|
| Base URL | https://public-api.luma.com |
| Auth header | x-luma-api-key: <key> |
| HTTP verbs used | Only GET and POST. Updates are POST .../update. Deletes are POST .../delete. There is no PATCH or DELETE anywhere in this API. |
| GET params | Query string |
| POST params | JSON body |
| List response envelope | { "entries": [...], "has_more": bool, "next_cursor": string } |
| Pagination inputs | pagination_cursor, pagination_limit (query params, GET only) |
| Rate limits | 200 req/min per calendar key, 500 req/min per org key. Over limit → HTTP 429. |
| Error bodies | Undocumented. The OpenAPI spec only defines 200 responses. Never assume an error JSON shape. |
| ID prefixes | Events evt-, guests gst-, ticket types ttype- |
| Account requirement | The calendar must have an active Luma Plus subscription, or the key won't work. |
Working directory is the repo root. It is an empty git repo on branch master with no commits.
- Create
package.json:
{
"name": "luma-mcp",
"version": "0.1.0",
"description": "MCP server for the Luma (lu.ma) Events API",
"type": "module",
"bin": { "luma-mcp": "./src/index.ts" },
"scripts": {
"dev": "bun run src/index.ts",
"test": "bun test",
"typecheck": "tsc --noEmit",
"check:coverage": "bun run scripts/check-coverage.ts",
"smoke": "bun run scripts/smoke.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.5.0"
}
}-
Run
bun install. If@modelcontextprotocol/sdkresolves to a version whose API differs from what this plan describes, stop and check the installed package's own types — the SDK API is the authority, not this document. -
Create
tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "test", "scripts"]
}- Vendor the API spec — this is the source of truth for every schema you write:
curl -sS -o openapi.json https://public-api.luma.com/openapi.json-
Create
.gitignorewithnode_modules/,.env,dist/. -
Create the directory skeleton:
src/tools/,test/,scripts/.
bun installsucceeded andnode_modules/@modelcontextprotocol/sdkexists.openapi.jsonis ~320 KB andbun -e "console.log(Object.keys(require('./openapi.json').paths).length)"prints68.
Read and validate environment on startup:
| Env var | Required | Default | Meaning |
|---|---|---|---|
LUMA_API_KEY |
yes | — | Sent as x-luma-api-key |
LUMA_API_BASE |
no | https://public-api.luma.com |
Override for tests/mocks |
LUMA_READ_ONLY |
no | unset | If set to 1 or true, write tools are never registered |
Export a loadConfig() returning { apiKey, baseUrl, readOnly }. If LUMA_API_KEY is missing,
throw with this exact guidance in the message: "Set LUMA_API_KEY. Generate a key in your Luma
calendar under Settings → Developer. The calendar needs an active Luma Plus subscription."
Write a LumaClient class. Because the API only uses GET and POST, it needs exactly two methods.
export class LumaApiError extends Error {
constructor(
readonly status: number,
readonly body: string,
readonly path: string,
) {
super(`Luma API ${status} on ${path}: ${body.slice(0, 500)}`);
}
}
export class LumaClient {
constructor(private readonly cfg: { apiKey: string; baseUrl: string }) {}
async get(path: string, query?: Record<string, unknown>): Promise<unknown> { /* ... */ }
async post(path: string, body?: Record<string, unknown>): Promise<unknown> { /* ... */ }
}Requirements for the shared request routine:
- Header: always send
x-luma-api-keyandaccept: application/json. POST also sendscontent-type: application/json. - Query building: skip keys whose value is
undefinedornull. For array values, emit a repeated param (?platforms=luma&platforms=external) — not a comma-joined string. The spec explicitly requires repeated params forplatformsandaccess. - Body building: strip
undefinedkeys beforeJSON.stringify. Do not stripnull— some fields (description,max_capacity,valid_end_at) acceptnullas a meaningful "clear this value" signal. - 429 handling: retry up to 3 times total. If a
Retry-Afterheader is present, wait that many seconds; otherwise back off500ms * 2^attempt. After the last attempt, throwLumaApiError. - Other non-2xx: read the response as text (not JSON — the shape is undocumented and may be
HTML) and throw
LumaApiError(status, text, path). - 2xx: parse and return JSON.
Do not add response type parsing here. The client returns unknown; tools shape the output.
bun run typecheck passes. No tests yet.
MCP tool results go into the model's context, so raw API payloads must be trimmed.
Export these helpers:
-
toResult(data: unknown, text?: string)— returns the MCP content shape:{ content: [{ type: "text", text: text ?? JSON.stringify(data, null, 2) }], structuredContent: data }. -
toErrorResult(err: unknown)— returns{ isError: true, content: [{ type: "text", text: ... }] }. For aLumaApiError, the text must include the HTTP status and the raw body. Never swallow it. -
slimGuest(entry, verbose)— whenverboseis false (the default), delete these noisy fields from a guest object:check_in_qr_code,eth_address,solana_address,registration_answers,utm_source. Keepid,user_email,user_name,approval_status,registered_at,joined_at,event_tickets. -
paginated(res, mapEntry?)— takes a raw list response and returns{ entries: mapped[], has_more, next_cursor }. The tool output must surfacenext_cursorso the model can page explicitly. Never auto-paginate silently.
bun run typecheck passes.
- One file per resource group in
src/tools/. - Each file exports an array of tool definitions:
{ name, description, inputSchema: z.object({...}), readOnly: true|false, handler: (args, client) => Promise<result> }. - Tool names are prefixed
luma_. - Zod field descriptions matter — the model reads them to decide what to send. Copy the wording
from
openapi.jsondescriptions where one exists (e.g.event_id→ "Event ID, this usually starts with evt-"). - Every handler wraps its body in try/catch and returns
toErrorResult(err)on throw.
| Tool | Endpoint | Args |
|---|---|---|
luma_get_self |
GET /v1/users/get-self |
none |
Use this as the connectivity/auth smoke tool. Its description should say so.
| Tool | Endpoint | Args |
|---|---|---|
luma_list_events |
GET /v1/calendars/events/list |
before?, after? (both ISO 8601 date-time), pagination_cursor?, pagination_limit? (default 50), platforms? (array of luma | external), sort_column? (start_at), sort_direction? (asc | desc | asc nulls last | desc nulls last), status? (approved | pending, defaults approved), access? (array of manage | view, defaults manage) |
luma_lookup_event |
GET /v1/calendars/events/lookup |
url?, event_id?, platform? (luma | external). Do not expose event_api_id — it is deprecated. |
luma_get_event |
GET /v1/events/get |
event_id (required) |
luma_list_events must default pagination_limit to 50 to protect context, and pass results
through paginated().
| Tool | Endpoint | Args |
|---|---|---|
luma_list_guests |
GET /v1/events/guests/list |
event_id (required), approval_status? (approved | session | pending_approval | invited | declined | waitlist), pagination_cursor?, pagination_limit? (default 50), sort_column? (name | email | created_at | registered_at | checked_in_at), sort_direction?, verbose? (boolean, default false) |
luma_get_guest |
GET /v1/events/guests/get |
event_id (required), id (required), verbose? |
Both run entries through slimGuest.
| Tool | Endpoint | Args |
|---|---|---|
luma_list_ticket_types |
GET /v1/events/ticket-types/list |
event_id (required), include_hidden? |
| Tool | Endpoint | Args |
|---|---|---|
luma_list_coupons |
GET /v1/events/coupons/list |
event_id (required), pagination_cursor?, pagination_limit? |
bun run typecheck passes and all 8 read tools are exported.
Export getTools(readOnly: boolean) which concatenates every tool module's array and, when
readOnly is true, filters out every tool with readOnly: false. Filtering at registration time
is deliberate: write tools must not even appear in the client's tool list in read-only mode.
#!/usr/bin/env bunshebang on line 1.loadConfig(), constructLumaClient.- Create the MCP
Server(nameluma-mcp, version from package.json) withcapabilities: { tools: {} }. - Register each tool from
getTools(cfg.readOnly). - Connect a
StdioServerTransport. - Never
console.log. stdout is the MCP protocol channel — any stray write corrupts it. All diagnostics go toconsole.error(stderr). - On startup config failure,
console.errorthe message andprocess.exit(1).
bunx @modelcontextprotocol/inspector bun src/index.tsWith a dummy LUMA_API_KEY=test set, the inspector must list exactly 8 tools. Then set
LUMA_READ_ONLY=1 and confirm it still lists 8 (no write tools exist yet). If stdout noise breaks
the connection, find and remove the console.log.
Cancellation is deliberately deferred to Phase 6. Add readOnly: false to every tool below.
luma_create_event → POST /v1/events/create. Required: name, start_at, timezone.
Optional: end_at, description_md, cover_url, slug, visibility (public | members-only |
private), location_visibility (public | guests-only), meeting_url, geo_address_json,
coordinate ({longitude, latitude}), max_capacity, waitlist_status (disabled | enabled),
registration_open, require_approval is not a field here (it lives on ticket types),
name_requirement (full-name | first-last), phone_number_requirement (optional |
required), can_register_for_multiple_tickets, reminders_disabled, show_guest_list,
tint_color, feedback_email, registration_questions.
Two things to get right:
timezoneis an IANA name (America/New_York), not an offset. Say so in the description.geo_address_jsonis aoneOf: either{ type: "manual", address: string }or{ type: "google", place_id: string, ... }. Model it as a Zod discriminated union ontype.registration_questionsis a complex array of question objects discriminated byquestion_type. If modelling it fully costs too much, acceptz.array(z.record(z.unknown()))and note the limitation in the description — but read the spec first and try the real union.
luma_update_event → POST /v1/events/update. Required: event_id. Every create field is
optional here, plus suppress_notifications. Only send keys the caller supplied — a partial update.
luma_add_guests → POST /v1/events/guests/add. Required: event_id, guests (array of
{ email: string, name?: string|null, registration_answers?: ... }). Optional: approval_status
(defaults approved; pending_approval and waitlist also valid), send_email (defaults true),
and either ticket ({event_ticket_type_id}) or tickets (array) — the API rejects both
together, so enforce mutual exclusion in the Zod schema with .refine() and a clear error message.
luma_update_guest_status → POST /v1/events/guests/update-status. Required: event_id,
guest_id, status (approved | declined | pending_approval | waitlist). Optional:
should_refund (default false), send_email (default true), message (max 200 chars, and the
API forbids combining it with send_email: false — enforce both in Zod).
Note guest_id accepts a gst- ID, a ticket key, a g- guest key, or the guest's email.
luma_send_invites → POST /v1/events/guests/send-invites. Required: event_id, guests
(array of { email, name? }). Optional: message (max 200 chars).
luma_create_ticket_type → POST /v1/events/ticket-types/create. Required: event_id, name,
type (free | paid). Optional: description, require_approval, is_hidden, valid_start_at,
valid_end_at (ISO 8601 date, e.g. 2026-09-01), max_capacity, cents, currency,
is_flexible, min_cents.
Make the description state that cents is minor units (500 = $5.00) and is required when
type: "paid".
luma_update_ticket_type → POST /v1/events/ticket-types/update. Required:
event_ticket_type_id (starts with ttype-). All other fields above are optional.
luma_create_coupon → POST /v1/events/coupons/create. Required: code (1–20 chars, case
insensitive), discount, event_id. Optional: remaining_count (0–1000000; 1000000 means
unlimited), valid_start_at, valid_end_at (ISO 8601 date-time), event_ticket_type_id.
discount is a oneOf — model it as a Zod discriminated union on discount_type:
{ discount_type: "percent", percent_off: number 0-100 } or the amount variant (read the exact
second branch out of openapi.json; do not guess the field names).
luma_add_host → POST /v1/events/hosts/add. Required: event_id, email. Optional:
access_level (none | check-in | manager, defaults manager), is_visible (defaults true),
name (ignored if the person already has a Luma profile).
Inspector lists 17 tools with LUMA_READ_ONLY unset, and 8 with LUMA_READ_ONLY=1.
Cancellation is a two-step flow, and this is the only place in the codebase where one tool call makes two API calls.
POST /v1/events/cancel/requestwith{ event_id }→ response contains acancellation_token.POST /v1/events/cancelwith{ event_id, cancellation_token, should_refund }.
- Args:
event_id(required),confirm(required,z.literal(true)),should_refund(optional, default false). - The
confirmliteral is a deliberate guard: it forces the destructive intent to appear in the arguments the user sees in their permission prompt. Do not make it optional. - The handler calls step 1, extracts
cancellation_tokenfrom the response, then calls step 2. - If step 1's response has no
cancellation_token, return an error result including the raw response body — do not proceed to step 2 with a missing token. - The tool description must state plainly that this cancels a live event and notifies guests.
Inspector lists 18 tools. luma_cancel_event is absent under LUMA_READ_ONLY=1.
There is no Luma API key available, so every test mocks fetch. Use bun:test and stub
globalThis.fetch.
Build fixtures from the 200 response schemas in openapi.json — do not invent response shapes
from memory. For example, the guest-list fixture must be
{ entries: [...], has_more: false, next_cursor: "..." }.
test/client.test.ts:
- GET sends the
x-luma-api-keyheader. - Query building drops
undefined/nullkeys. - Array params are emitted as repeated params (
platforms=luma&platforms=external), not comma-joined. - POST body drops
undefinedbut preserves explicitnull. - A
429followed by a200succeeds after retry; three consecutive429s throwLumaApiError. - A
500with an HTML body throwsLumaApiErrorcarrying status 500 and the raw text (does not crash on JSON parse).
test/tools.test.ts:
7. getTools(false) returns 18 tools; getTools(true) returns 8, and none of them have
readOnly: false.
8. luma_list_guests strips check_in_qr_code / eth_address / solana_address by default and
keeps them when verbose: true.
9. luma_list_events defaults pagination_limit to 50 and surfaces next_cursor in its output.
10. luma_add_guests rejects args containing both ticket and tickets.
11. luma_update_guest_status rejects message longer than 200 chars, and rejects
message combined with send_email: false.
12. luma_cancel_event issues two fetches in order, and passes the token from call 1 into
call 2; and errors without a second call when call 1 returns no token.
bun test — all green. bun run typecheck — clean.
Load openapi.json and getTools(false). Each tool definition should carry an endpoint field
(e.g. "GET /v1/events/get") for this purpose — add it in Phase 3/5 if you haven't.
The script must:
- Fail (exit 1) if any registered tool references a path not present in the spec. This catches typos and API drift.
- Print the list of spec endpoints with no tool (expect ~50), grouped by prefix, so future work is visible.
Live smoke test, skipped with exit 0 when LUMA_API_KEY is unset. When a key is present:
get-self → list_events → create a private test event → cancel it. This is deferred work; write
the script now, run it later.
Must cover: what it does; the Luma Plus requirement and where to get a key (calendar Settings → Developer); env vars table; the install command below; the full 18-tool table; and an explicit "Not yet covered" section listing contacts, contact/event tags, memberships, webhooks v2, organization endpoints, and image upload — so users know the boundary.
claude mcp add luma -e LUMA_API_KEY=<key> -- bun /absolute/path/to/luma-mcp/src/index.tsbun run check:coverage exits 0 and prints the uncovered-endpoint list. /mcp in Claude Code shows
the luma server connected with 18 tools.
Everything above is verified only against mocks. The OpenAPI spec is the sole source for
request/response shapes, and it documents no error responses at all. Until bun run smoke runs
against a real Luma Plus calendar, treat the server as unvalidated against the live API — in
particular the error handling path, geo_address_json, registration_questions, and the coupon
discount union.
Contacts (/v1/calendars/contacts/*), contact tags, event tags, memberships
(/v1/memberships/*), webhooks (/v2/webhooks/* — note the v2 prefix on create/update/get but v1
on list/delete), organization endpoints (/v1/organizations/*), calendar-level coupons, calendar
event add/approve/reject, and image upload URLs. Roughly 50 endpoints. Adding all of them would put
~68 tools in the client's tool list, which degrades tool selection — a future version should group
them behind fewer, more general tools.