Featured

Deploy OpenClaw in 60 seconds — 20% off logoDeploy OpenClaw in 60 seconds — 20% off

Launch OpenClaw on Hostinger in about 60 seconds and keep your agent live 24/7. Our referral link gives you 20% off, no coupon code needed.

Launch on Hostinger
Run your Hermes agent on Hostinger, fully managed logoRun your Hermes agent on Hostinger, fully managed

Launch Hermes on Hostinger in one click, fully managed, no VPS knowledge needed. Use code ZACAARON10 for 10% off.

Launch on Hostinger
Crawl and scrape any site into clean data, 10% off logoCrawl and scrape any site into clean data, 10% off

Firecrawl crawls and scrapes any site into clean markdown for your agent. Get 1,000 free credits, and new users get 10% off their first purchase.

Try Firecrawl free
Your own AI agent, running 24/7 with QwikClaw logoYour own AI agent, running 24/7 with QwikClaw

QwikClaw sets up and runs an always-on OpenClaw agent for you. One click, no config files, no server setup.

Deploy now
One API to scrape, enrich, and extract the internet. logoOne API to scrape, enrich, and extract the internet.

Context.dev gives your agents a single API to scrape, enrich, and extract live web data — no proxies, no parsers, no maintenance.

Start building free
SetupClaw: done-for-you OpenClaw for founders & exec teams logoSetupClaw: done-for-you OpenClaw for founders & exec teams

White-glove OpenClaw for founders and exec teams (4–50+ employees): we install, harden, integrate your tools, and maintain it — secured from day one.

Get it set up for you
SEO data APIs for your agent, $1 free credit logoSEO data APIs for your agent, $1 free credit

DataForSEO gives your agent live access to SERP results, keyword data, backlinks, and on-page SEO data through one API. New accounts get a $1 credit, good for up to 20,000 keyword or backlink lookups.

Try DataForSEO free
Reach 47,000+ AI builders

A flat monthly placement in front of developers actively installing AI tools. No lock-in, cancel anytime.

Advertise here

Works with

Claude CodeClaude DesktopCursorVS CodeClineCodex CLIOpenClaw+ any MCP client

Install to Claude Code

This server doesn't publish a one-line install command. Follow the setup in the source repository.

Summary

MCP server for managing events on Meetup.com and Luma via AI assistants like Claude.

README.md

<p align="center"> <picture> <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.png"> <source media="(prefers-color-scheme: light)" srcset="docs/logo-light.png"> <img src="docs/logo-light.png" alt="PulumiEvents" width="400"> </picture> </p>

Pulumi Events MCP

MCP server for managing events on Meetup.com and Luma via AI assistants like Claude.

Built with FastMCP 3.x, it exposes Meetup's GraphQL API and Luma's REST API as MCP tools and resources so LLMs can search events, manage groups, create and publish events, and more.

Features

  • 27 tools across two platforms (Meetup + Luma), tagged by platform and domain
  • Invite-only Luma events end-to-end — require-approval, capacity + waitlist, registration questions, host management, and members-only visibility, all without touching the Luma UI
  • 6 resources for read-only lookups (user profiles, group/event/network details)
  • Cover image upload — pass a local file path when creating/updating events; the server handles CDN upload automatically (Luma presigned URL, Meetup two-step photo upload)
  • Auto-pagination on all list tools — single tool call returns all results
  • Meetup: JWT authentication for headless server-to-server access (no browser needed), with OAuth2 fallback
  • Meetup Pro network search with member metadata (roles, events attended, group counts)
  • Luma: API key authentication
  • Stateless HTTP transport — no stale session issues on server restarts
  • Provider architecture for easy addition of new platforms

Middleware

The server uses FastMCP's middleware stack for reliability and observability:

| Middleware | Purpose | |---|---| | ErrorHandlingMiddleware | Converts raw exceptions to proper MCP error codes, logs errors consistently | | RetryMiddleware | Automatic retry with exponential backoff on transient network failures (ConnectionError, TimeoutError) | | ResponseCachingMiddleware | 5-minute TTL cache on read-only tools (list, search, get) — mutations are never cached; luma_get_event is also excluded so verify-after-update reads are never stale |

Tool Metadata

All tools include FastMCP metadata for better LLM integration:

  • Tags — every tool is tagged by platform (meetup, luma) and domain (events, groups, members, etc.) for discovery and filtering
  • Timeouts — 120-second timeout on upload-capable tools and all auto-paginating list tools to prevent hangs on slow networks
  • Output schemas — key tools declare their response structure so LLM clients know what fields to expect
  • Annotations — read-only tools are marked with readOnlyHint, idempotent tools with idempotentHint

Quick start

See the Getting Started guide for full setup instructions.

Cloud (deployed)

The server runs on AWS ECS Fargate behind CloudFront. Claude Code and Claude Desktop speak remote HTTP MCP natively — no proxy process needed:

claude mcp add --transport http pulumi-events https://<your-cloudfront-domain>/mcp

Claude handles the Google OAuth flow itself (/mcp → authenticate) and manages token refresh. For clients that only support stdio servers, fall back to mcp-remote:

{
  "mcpServers": {
    "pulumi-events": {
      "command": "npx",
      "args": ["mcp-remote", "https://<your-cloudfront-domain>/mcp"]
    }
  }
}

Headless environments (CI, sandboxes, scheduled agents) can't complete a browser OAuth flow. Set PULUMI_EVENTS_AUTH_TOKEN on the server and connect with the pre-shared token instead — it works alongside Google OAuth, not instead of it:

claude mcp add --transport http pulumi-events https://<your-cloudfront-domain>/mcp \
  --header "Authorization: Bearer <token>"

Google OAuth handles interactive MCP auth. Meetup authenticates automatically via JWT on server startup. The CloudFront domain is output by pulumi stack output cloudfront_url after deployment.

Local development

# Prerequisites: Python 3.12+, uv, Pulumi CLI (logged in)
git clone https://github.com/dirien/pulumi-events.git
cd pulumi-events
uv sync

# Start the server (credentials injected via Pulumi ESC)
pulumi env run ediri/pulumi-idp/auth -- uv run pulumi-events

Then point Claude Code at http://127.0.0.1:8080/mcp.

Tools

Meetup

| Tool | Tags | Description | |------|------|-------------| | list_platforms | platform | List all configured platforms with auth status | | meetup_login | meetup, auth | Start Meetup OAuth2 login flow | | meetup_get_event | meetup, events | Get full details of a Meetup event by ID | | meetup_list_group_events | meetup, events | List events for a group (including drafts) with status filter | | meetup_search_events | meetup, events, search | Search events with filters (lat/lon required, date, type) | | meetup_search_groups | meetup, groups, search | Search groups by keyword and location (lat/lon required) | | meetup_list_my_groups | meetup, groups | List all groups you belong to | | meetup_create_event | meetup, events | Create an event (defaults to DRAFT). Supports featured_image_path for cover photo upload | | meetup_edit_event | meetup, events | Edit an existing event. Supports featured_image_path for cover photo upload | | meetup_event_action | meetup, events | Delete, publish, announce, or manage RSVPs | | meetup_network_search | meetup, network, search | Search events, groups, or members within a Pro network | | meetup_list_group_members | meetup, members | List members of a group with roles and join dates | | meetup_get_member | meetup, members | Get details of a specific member in a group | | meetup_find_member | meetup, members | Find a member across all your groups (cross-group lookup) | | meetup_create_venue | meetup, venues | Create a venue for events |

Luma

| Tool | Tags | Description | |------|------|-------------| | luma_list_events | luma, events | List events from your Luma calendar | | luma_get_event | luma, events | Get full details of a Luma event by API ID, including visible hosts, registration questions, and require-approval state | | luma_create_event | luma, events | Create a Luma event — including invite-only ones: require_approval, max_capacity, waitlist_status, registration_questions, visibility (public/members-only/private), plus cover image upload and tint_color | | luma_update_event | luma, events | Update a Luma event. Same field set as create, plus suppress_notifications | | luma_cancel_event | luma, events | Cancel a Luma event | | luma_list_ticket_types | luma, events | List an event's ticket types with their require_approval flags (authoritative, no lag) | | luma_add_host | luma, hosts | Add a host/co-host by email with access_level (manager/check-in/none) and visibility | | luma_update_host | luma, hosts | Change a host's access level or visibility | | luma_remove_host | luma, hosts | Remove a host (the event creator cannot be removed) | | luma_list_people | luma, people | List all people from your Luma calendar | | luma_list_guests | luma, guests | List guests for a Luma event | | luma_send_invites | luma, guests | Send invite emails for a Luma event to an array of guests (email + optional name) |

Image Upload

Both platforms support event cover images through their create/update tools:

  • Luma: Pass cover_image_path (local file path) to luma_create_event or luma_update_event. The server uploads to Luma's CDN via a presigned URL and sets the cover_url automatically.
  • Meetup: Pass featured_image_path (local file path) to meetup_create_event or meetup_edit_event. The server uploads via Meetup's createGroupEventPhoto mutation and sets the featuredPhotoId. For create, the event is created first, then the photo is uploaded and attached via an edit (since Meetup's CreateEventInput doesn't support featuredPhotoId).

Supported image formats: JPEG, PNG, GIF, WebP, SVG, AVIF.

Sending Invites (Luma)

luma_send_invites takes an array of guests, each with a required email and an optional name:

{
  "event_id": "evt-abc123",
  "guests": [
    { "email": "ada@example.com", "name": "Ada Lovelace" },
    { "email": "alan@example.com" }
  ],
  "message": "Hope to see you there!"
}

name is ignored for people who already have a name on their Luma account. message is a single note applied to the whole batch (max 200 characters), not a per-guest field. People who are not yet on the guest list are added and invited.

Event Theming (Luma)

Pass tint_color (a hex string, e.g. "#bb2dc7") to luma_create_event or luma_update_event to set the event page theme color. Luma derives contrast-adjusted shades from it; alpha channels are stripped automatically. There is no API field for custom background images — only cover_url and tint_color.

New events default to #2f2356 when tint_color is omitted. Configure via PULUMI_EVENTS_LUMA_DEFAULT_TINT_COLOR (empty string disables the default); the cloud deployment reads it from the marketing/pulumi-events ESC environment (pulumi-events-infra:lumaDefaultTintColor). Updates never apply the default — an existing color is only changed when tint_color is passed explicitly.

Invite-Only Events (Luma)

A single luma_create_event call (plus luma_add_host per co-host) produces a fully configured invite-only event — private visibility, request-to-join approval, capacity with waitlist, and a custom registration form — with no Luma UI steps:

{
  "name": "Engineering Leadership Dinner",
  "start_at": "2026-09-10T17:00:00Z",
  "end_at": "2026-09-10T20:00:00Z",
  "timezone": "Europe/Berlin",
  "visibility": "private",
  "require_approval": true,
  "max_capacity": 12,
  "waitlist_status": "enabled",
  "registration_questions": [
    { "label": "Tell us more about you.", "required": true, "question_type": "text" }
  ],
  "cover_image_url": "https://example.com/cover.png"
}

The tool response is the event fetched back from the API, so most settings are verifiable in one round trip. Field-level behavior of Luma's public API (verified empirically):

| Field | Behavior | |---|---| | require_approval | Not an event field — Luma stores it per ticket type. The server sets it on all of the event's ticket types (new events have one free "Standard" type) and returns the applied state as ticket_types in the response. The event-level require_approval in GET responses is derived and refreshed asynchronously (may read false for a few seconds); luma_list_ticket_types is always current. | | max_capacity | Write-only: GET returns max_capacity: null even when set. Verify via spots_remaining (also refreshed asynchronously). Pass 0 on update to remove the limit. | | waitlist_status | enabled/disabled; round-trips in GET. Only meaningful with a capacity. | | registration_questions | Round-trip in GET. Luma requires an id per question — the server auto-generates one from the label when omitted. Updates replace the whole set; resend existing questions with their IDs to keep them. Types: text, long-text, dropdown/multi-select (+options), url, phone-number, company, agree-check, and social handles. Luma's rich-text terms type is not supported. | | visibility | public, members-only, or private; round-trips in GET. | | Hosts | luma_add_host creates a placeholder profile for emails without a Luma account (using name). GET only returns visible hosts — a host with is_visible: false exists but never appears in API responses, and access_level is write-only. The event creator cannot be removed or downgraded. | | show_guest_list, name_requirement, phone_number_requirement | Accepted and applied, but write-only — GET always returns null, so they cannot be verified via the API. | | location_visibility | public or guests-only (hide exact address from unapproved guests); round-trips in GET. | | slug | Custom URL (https://luma.com/<slug>, 3–50 chars, unique across all of Luma); verify via the url field in GET. |

Cross-Platform: Meetup to Luma

When copying an event from Meetup to Luma, the LLM should look up the venue's Google Maps place ID and pass it as geo_address_json:

// Recommended — Google Maps place ID (most reliable)
{"type": "google", "place_id": "ChIJJzpTdyB0nkcRblzKCp3kxeQ"}

Luma resolves the full address, coordinates, and map pin from the place ID automatically. Do NOT pass raw Meetup venue objects — they use an incompatible format. The server also strips invalid type fields server-side as a safety net.

Resources

| URI | Description | |-----|-------------| | meetup://self | Authenticated Meetup user profile | | meetup://group/{urlname} | Meetup group details by URL name | | meetup://event/{event_id} | Meetup event details by ID | | meetup://network/{urlname} | Meetup Pro network info | | luma://self | Authenticated Luma user profile | | luma://event/{event_id} | Luma event details by API ID |

Configuration

All settings are loaded from environment variables with the PULUMI_EVENTS_ prefix:

| Variable | Default | Description | |----------|---------|-------------| | PULUMI_EVENTS_MEETUP_CLIENT_ID | — | Meetup OAuth client ID | | PULUMI_EVENTS_LUMA_API_KEY | — | Luma API key (requires Luma Plus) | | PULUMI_EVENTS_SERVER_HOST | 127.0.0.1 | Server bind address | | PULUMI_EVENTS_SERVER_PORT | 8080 | Server port | | PULUMI_EVENTS_TOKEN_CACHE_DIR | ~/.config/pulumi-events | Token cache directory | | PULUMI_EVENTS_AUTH_TOKEN | — | Bearer token for MCP endpoint auth (optional) | | PULUMI_EVENTS_AUTO_OPEN_BROWSER | false | Auto-open browser for OAuth login | | PULUMI_EVENTS_MEETUP_PRO_NETWORK_URLNAME | pugs | Default Meetup Pro network URL name | | PULUMI_EVENTS_LUMA_DEFAULT_TINT_COLOR | #2f2356 | Default theme color for new Luma events (empty disables) | | PULUMI_EVENTS_BASE_URL | — | Public URL override (e.g. CloudFront domain) | | PULUMI_EVENTS_MEETUP_JWT_SIGNING_KEY | — | RSA private key PEM for Meetup JWT auth | | PULUMI_EVENTS_MEETUP_JWT_KEY_ID | — | Meetup signing key ID (kid) | | PULUMI_EVENTS_MEETUP_MEMBER_ID | — | Meetup member ID for JWT auth (sub claim) | | PULUMI_EVENTS_MEETUP_TOKEN_BACKEND | file | Token backend: file or env | | PULUMI_EVENTS_GOOGLE_CLIENT_ID | — | Google OAuth client ID (for MCP auth) | | PULUMI_EVENTS_GOOGLE_CLIENT_SECRET | — | Google OAuth client secret |

Authentication

The MCP endpoint supports two auth modes that compose:

  • Google OAuth (when PULUMI_EVENTS_GOOGLE_CLIENT_ID/_SECRET are set) — the interactive flow for browser-capable clients.
  • Pre-shared bearer token (when PULUMI_EVENTS_AUTH_TOKEN is set) — for headless clients. If Google credentials are also configured, the token is accepted in addition to Google-issued tokens; requests bearing neither are rejected. With only the token set, all MCP requests must include the Authorization: Bearer <token> header.

When neither is set, the server runs without auth (the default for local development).

To enable:

export PULUMI_EVENTS_AUTH_TOKEN="your-secret-token"

MCP clients must then send the token in the Authorization header. Example Claude Code config:

{
  "mcpServers": {
    "pulumi-events": {
      "type": "streamable-http",
      "url": "http://127.0.0.1:8080/mcp",
      "headers": {
        "Authorization": "Bearer your-secret-token"
      }
    }
  }
}

Health (/health) and OAuth callback (/auth/meetup/callback) routes are not affected by MCP auth.

Project Structure

src/pulumi_events/
├── server.py              # FastMCP instance, lifespan, middleware, custom routes
├── settings.py            # Pydantic Settings configuration
├── exceptions.py          # Exception hierarchy
├── utils.py               # Shared utilities (image MIME type detection)
├── auth/
│   ├── backends.py        # Pluggable token backends (File, Env)
│   ├── jwt_auth.py        # Meetup JWT auth (headless, server-to-server)
│   ├── oauth.py           # OAuth2 flow helpers (Meetup)
│   └── token_store.py     # Token persistence + auto-refresh
├── providers/
│   ├── base.py            # EventProvider protocol + capabilities
│   ├── registry.py        # Provider registry
│   ├── meetup/
│   │   ├── client.py      # GraphQL client with auto token refresh + binary upload
│   │   ├── provider.py    # MeetupProvider implementation + photo upload
│   │   ├── queries.py     # GraphQL query/mutation strings
│   │   └── models.py      # Pydantic response models
│   └── luma/
│       ├── client.py      # REST client for Luma public API + image upload
│       └── provider.py    # LumaProvider implementation
├── tools/
│   ├── _deps.py           # Shared dependency factories
│   ├── platform_tools.py  # list_platforms, meetup_login
│   ├── event_tools.py     # Meetup event mutations (with image upload)
│   ├── group_tools.py     # Meetup group tools
│   ├── member_tools.py    # Meetup member tools
│   ├── search_tools.py    # Meetup search tools
│   ├── venue_tools.py     # Meetup venue tools
│   └── luma_tools.py      # Luma event + guest tools (with image upload)
└── resources/
    ├── meetup_resources.py
    └── luma_resources.py

Deployment

The server is deployed to AWS ECS Fargate via Pulumi. Infrastructure code lives in deploy/.

deploy/
├── __main__.py       # Pulumi program (VPC, ECR, ECS, ALB, CloudFront, Secrets Manager)
├── Pulumi.yaml       # Project config (org: pulumi, toolchain: uv)
├── Pulumi.dev.yaml   # Stack config (ESC environments: marketing/aws-auth, marketing/pulumi-events)
├── pyproject.toml    # Pulumi SDK dependencies
└── uv.lock

Architecture: CloudFront (HTTPS) → ALB (HTTP) → ECS Fargate (port 8080)

Meetup authenticates headlessly via JWT on every container startup. Secrets are stored in AWS Secrets Manager and injected via ECS task definition. All config comes from Pulumi ESC.

cd deploy
pulumi up    # deploy or update
pulumi logs  # tail container logs

Development

make help       # Show all targets
make check      # Lint + format check
make format     # Auto-format
make test       # Run tests
make run        # Start the server

License

MIT

See related servers & alternatives →

Related MCP servers

Browse all →

Related guides

Hand-picked reading to help you choose and use Cloud & DevOps servers.