Skip to content

Latest commit

 

History

History
490 lines (354 loc) · 21.1 KB

File metadata and controls

490 lines (354 loc) · 21.1 KB

luma-mcp — Implementation Plan

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.md for 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.


Context

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).

API facts you must not get wrong

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.

Phase 0 — Scaffold the project

Working directory is the repo root. It is an empty git repo on branch master with no commits.

Steps

  1. 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"
  }
}
  1. Run bun install. If @modelcontextprotocol/sdk resolves 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.

  2. Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["bun-types"],
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src", "test", "scripts"]
}
  1. 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
  1. Create .gitignore with node_modules/, .env, dist/.

  2. Create the directory skeleton: src/tools/, test/, scripts/.

Checkpoint 0

  • bun install succeeded and node_modules/@modelcontextprotocol/sdk exists.
  • openapi.json is ~320 KB and bun -e "console.log(Object.keys(require('./openapi.json').paths).length)" prints 68.

Phase 1 — Config and HTTP client

Step 1.1 — src/config.ts

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."

Step 1.2 — src/client.ts

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:

  1. Header: always send x-luma-api-key and accept: application/json. POST also sends content-type: application/json.
  2. Query building: skip keys whose value is undefined or null. For array values, emit a repeated param (?platforms=luma&platforms=external) — not a comma-joined string. The spec explicitly requires repeated params for platforms and access.
  3. Body building: strip undefined keys before JSON.stringify. Do not strip null — some fields (description, max_capacity, valid_end_at) accept null as a meaningful "clear this value" signal.
  4. 429 handling: retry up to 3 times total. If a Retry-After header is present, wait that many seconds; otherwise back off 500ms * 2^attempt. After the last attempt, throw LumaApiError.
  5. Other non-2xx: read the response as text (not JSON — the shape is undocumented and may be HTML) and throw LumaApiError(status, text, path).
  6. 2xx: parse and return JSON.

Do not add response type parsing here. The client returns unknown; tools shape the output.

Checkpoint 1

bun run typecheck passes. No tests yet.


Phase 2 — Output formatting

src/format.ts

MCP tool results go into the model's context, so raw API payloads must be trimmed.

Export these helpers:

  1. toResult(data: unknown, text?: string) — returns the MCP content shape: { content: [{ type: "text", text: text ?? JSON.stringify(data, null, 2) }], structuredContent: data }.

  2. toErrorResult(err: unknown) — returns { isError: true, content: [{ type: "text", text: ... }] }. For a LumaApiError, the text must include the HTTP status and the raw body. Never swallow it.

  3. slimGuest(entry, verbose) — when verbose is false (the default), delete these noisy fields from a guest object: check_in_qr_code, eth_address, solana_address, registration_answers, utm_source. Keep id, user_email, user_name, approval_status, registered_at, joined_at, event_tickets.

  4. paginated(res, mapEntry?) — takes a raw list response and returns { entries: mapped[], has_more, next_cursor }. The tool output must surface next_cursor so the model can page explicitly. Never auto-paginate silently.

Checkpoint 2

bun run typecheck passes.


Phase 3 — Read tools (8)

Conventions for every tool file

  • 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.json descriptions 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.

Step 3.1 — src/tools/user.ts

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.

Step 3.2 — src/tools/events.ts (read half)

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().

Step 3.3 — src/tools/guests.ts (read half)

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.

Step 3.4 — src/tools/tickets.ts (read half)

Tool Endpoint Args
luma_list_ticket_types GET /v1/events/ticket-types/list event_id (required), include_hidden?

Step 3.5 — src/tools/coupons.ts (read half)

Tool Endpoint Args
luma_list_coupons GET /v1/events/coupons/list event_id (required), pagination_cursor?, pagination_limit?

Checkpoint 3

bun run typecheck passes and all 8 read tools are exported.


Phase 4 — Server bootstrap (first runnable milestone)

Step 4.1 — src/tools/index.ts

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.

Step 4.2 — src/index.ts

  1. #!/usr/bin/env bun shebang on line 1.
  2. loadConfig(), construct LumaClient.
  3. Create the MCP Server (name luma-mcp, version from package.json) with capabilities: { tools: {} }.
  4. Register each tool from getTools(cfg.readOnly).
  5. Connect a StdioServerTransport.
  6. Never console.log. stdout is the MCP protocol channel — any stray write corrupts it. All diagnostics go to console.error (stderr).
  7. On startup config failure, console.error the message and process.exit(1).

Checkpoint 4

bunx @modelcontextprotocol/inspector bun src/index.ts

With 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.


Phase 5 — Write tools (9 of 10)

Cancellation is deliberately deferred to Phase 6. Add readOnly: false to every tool below.

Step 5.1 — src/tools/events.ts (write half)

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:

  • timezone is an IANA name (America/New_York), not an offset. Say so in the description.
  • geo_address_json is a oneOf: either { type: "manual", address: string } or { type: "google", place_id: string, ... }. Model it as a Zod discriminated union on type.
  • registration_questions is a complex array of question objects discriminated by question_type. If modelling it fully costs too much, accept z.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.

Step 5.2 — src/tools/guests.ts (write half)

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).

Step 5.3 — src/tools/tickets.ts (write half)

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.

Step 5.4 — src/tools/coupons.ts (write half)

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).

Step 5.5 — src/tools/hosts.ts

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).

Checkpoint 5

Inspector lists 17 tools with LUMA_READ_ONLY unset, and 8 with LUMA_READ_ONLY=1.


Phase 6 — Event cancellation (destructive; handle separately)

Cancellation is a two-step flow, and this is the only place in the codebase where one tool call makes two API calls.

  1. POST /v1/events/cancel/request with { event_id } → response contains a cancellation_token.
  2. POST /v1/events/cancel with { event_id, cancellation_token, should_refund }.

luma_cancel_event (in src/tools/events.ts)

  • Args: event_id (required), confirm (required, z.literal(true)), should_refund (optional, default false).
  • The confirm literal 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_token from 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.

Checkpoint 6

Inspector lists 18 tools. luma_cancel_event is absent under LUMA_READ_ONLY=1.


Phase 7 — Tests

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: "..." }.

Required test cases

test/client.test.ts:

  1. GET sends the x-luma-api-key header.
  2. Query building drops undefined/null keys.
  3. Array params are emitted as repeated params (platforms=luma&platforms=external), not comma-joined.
  4. POST body drops undefined but preserves explicit null.
  5. A 429 followed by a 200 succeeds after retry; three consecutive 429s throw LumaApiError.
  6. A 500 with an HTML body throws LumaApiError carrying 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.

Checkpoint 7

bun test — all green. bun run typecheck — clean.


Phase 8 — Coverage script, docs, install

Step 8.1 — scripts/check-coverage.ts

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.

Step 8.2 — scripts/smoke.ts

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.

Step 8.3 — README.md

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.

Step 8.4 — Install locally

claude mcp add luma -e LUMA_API_KEY=<key> -- bun /absolute/path/to/luma-mcp/src/index.ts

Checkpoint 8

bun run check:coverage exits 0 and prints the uncovered-endpoint list. /mcp in Claude Code shows the luma server connected with 18 tools.


Phase 9 — Deferred: live validation

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.


Deferred to v2 (do not build now)

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.