Skip to content

MG Staff MCP Server

A remote MCP server that gives Metrognome staff LLM-driven tools for day-to-day member operations — looking up members, rescheduling bookings, managing lockouts, adjusting balances, creating checkouts — replacing manual use of the /staff GUI. It is a thin proxy over @mg/api: every tool maps to an existing API endpoint, called on behalf of the signed-in staffer via the token-exchange primitive. The server owns zero business logic.

Why

Staff work is full of repetitive, multi-step operational tasks the /staff GUI handles one click at a time. Exposing those operations as MCP tools lets a staffer drive them through Claude (Cowork / claude.ai) in natural language, with the LLM composing lookups and actions. Because every action runs as the real staffer (per-staff attribution) through the API's existing business logic, RBAC, and audit trail, the MCP adds capability without duplicating or weakening any of it.

Constraints

  • Depends on token-exchange (PR #748). The MCP cannot function until that endpoint is merged and a ServicePrincipal (staff-mcp) exists with oidcAudience set. The endpoint fails closed until then.
  • Stack: TypeScript + punkpeye/fastmcp, Streamable HTTP transport. Chosen for built-in OAuth 2.1 + Google provider (mirrors the Google Workspace MCP) and tool annotations.
  • Proxy only. Tools call @mg/api over HTTP; no DB access, no reimplemented logic. The only coupling to the API is the HTTP contract of the endpoints it wraps.
  • Per-staff attribution; delegations capped to STAFF. token-exchange delegates as the verified staffer; staff-mcp sets delegatedRoleCap: 'STAFF', so admins are valid subjects but every delegated call presents STAFF claims — never admin. Blast radius is bounded by the cap and the curated tool catalog, not RBAC. Authorization is role-based (ADR-006); there are no operation scopes.
  • Deploy: homelab Docker + Cloudflare tunnel, mirroring /opt/homelab/google-workspace-mcp (container on 127.0.0.1:<port>, staff-mcp.metrognome.com ingress).
  • Lives at apps/staff-mcp in the monorepo (so API changes and the MCP's contract tests share one CI).

Auth flow

sequenceDiagram
  participant Staffer
  participant Cowork as claude.ai / Cowork
  participant MCP as staff-mcp (FastMCP)
  participant TX as @mg/api /token-exchange
  participant API as @mg/api (tool route)

  Staffer->>Cowork: use a tool
  Cowork->>MCP: Streamable HTTP (OAuth 2.1)
  MCP->>Staffer: Google login (first use) — FastMCP OAuth proxy
  Note over MCP: holds staffer's Google id_token + client secret
  MCP->>TX: POST /token-exchange (Basic client auth + id_token)
  TX->>TX: verify id_token (aud=oidcAudience, exp, email_verified) → map to staff user
  TX-->>MCP: delegation token (15-min TTL)
  MCP->>API: tool call with x-service-delegation: <token>
  API->>API: validateAuthentication → staffer's roles; business logic + audit run as staffer
  API-->>MCP: result → tool result

oidcAudience: the MCP registers its own Google OAuth client; that client_id is the aud on the id_tokens FastMCP receives, and is what staff-mcp.oidcAudience must be set to. Registering this client + setting oidcAudience is part of this work and is what brings token-exchange online.

Session lifetime: GoogleOfflineProvider bakes access_type=offline&prompt=consent into the Google authorization URL. Without it Google issues no refresh token, so FastMCP's token swap hands claude.ai a ~1h JWT with no refresh token and every session hard-expires an hour after login (the "staff reconnect several times a week" bug). With it, sessions renew silently; the tradeoff is Google's consent screen on every login, required so re-auth re-issues the refresh token. Tokens persist to MCP_TOKEN_STORE_DIR (encrypted with MCP_ENCRYPTION_KEY) so container restarts don't sever live sessions — the container must bind-mount a host path at /tokens.

Tool catalog (phased)

Each tool wraps the verified endpoint(s) below. Routes/gates confirmed against current code. Tools carry MCP annotations (readOnlyHint / destructiveHint / idempotentHint).

Phase 1 — read-only (proves the OAuth → token-exchange → API pipe)

Tool Endpoint(s) Current gate Notes
get_member GET /api/staff/search?q= (fuzzy) → GET /api/users/[id] isStaffOrAbove search then detail
get_member_reservations GET /api/reservations?userId= isStaffOrAbove filter resourceType, status
get_member_balance GET /api/users/balances?userId= (credits) + GET /api/users/[id]/stripe-balance credits isStaffOrAbove; Stripe balance isAdmin Stripe-balance read needs downscope (see below)
list_lockouts GET /api/reservations?resourceType=STUDIO_MONTHLY (+ userId/locationId) isStaffOrAbove no dedicated lockouts list endpoint
get_member_access_code GET /api/access-codes/by-user/[userId] isStaff current active code only; PIN masked to last 2
get_member_payments GET /api/payments?search= isStaffOrAbove matches by email/name (route has no userId filter); includes refund, no Stripe ids
get_waitlist GET /api/waitlist?userId=\|locationId=&sortOrder=asc isStaffOrAbove member- or location-centric; FIFO position
get_studio_pricing GET /api/resources?search= isStaffOrAbove card/ACH/credit + peak window; search term, not id
get_studio_reference GET /api/staff/studio-reference?search= isStaffOrAbove monthly-studio ops sheet; door codes masked to last 2
list_checkout_invitations GET /api/checkout/session?search=&type=&status= isStaffOrAbove staff-created checkout/waitlist invites; derived pending/claimed/cancelled
list_inquiries GET /api/inquiries?type=&status=&channel=&locationId=&search= isStaffOrAbove inbound inquiries, newest first; filter by type/status/channel/location/sender
get_inquiry GET /api/inquiries/[id] isStaffOrAbove full detail for one inquiry incl. metadata + linked member/location
inquiry_summary GET /api/inquiries/summary isStaffOrAbove counts grouped by type & status (triage); excludes soft-deleted; new endpoint

Inquiry types: STUDIO_INTEREST, WAITLIST_REQUEST, RESERVATION_HELP, GROUP_MEMBERSHIP, BILLING, TECHNICAL_ISSUE, EMAIL_CAPTURE, GREEN_ROOM_REGISTRATION, OTHER.

Phase 2 — writes (reversible-ish; destructiveHint, preview-then-confirm)

Tool Endpoint Gate Side effects
reschedule_hourly_reservation PUT /api/reservations/[id] own-or-hasScopedRoleAt Acuity reschedule, access-code regen
cancel_hourly_reservation POST /api/reservations/[id]/cancel own-or-hasScopedRoleAt credit restore, Stripe refund, Acuity cancel, emails

Phase 3 — money / access (highest blast radius; destructiveHint + explicit confirm)

Tool Endpoint Gate Side effects
cancel_lockout POST /api/lockouts/[id]/update {action:"cancel", mode} isStaffOrAboveAt Stripe subscription cancel/schedule
create_checkout POST /api/checkout/session (type: waitlist\|monthly\|hourly) → POST /api/checkout/session/[id]/send-invitation isStaffOrAbove creates a CheckoutInvitation link; emails member. Not an immediate charge (relatively safe)
adjust_member_balance POST /api/users/[id]/stripe-balance (Stripe customer balance only) isAdmin → staff (downscoped) Stripe addCustomerBalanceCredit + transaction audit record. Raw credit-balance write NOT included (stays admin-only)

Admin-route downscoping (approved)

Aaron approved downscoping select admin actions to staff. These routes change isAdminisStaffOrAbove, done as deliberate @mg/api changes alongside the relevant phase:

Route Phase Change Note
GET /api/users/[id]/stripe-balance 1 isStaffOrAbove read-only; low risk
POST /api/users/[id]/stripe-balance 3 isStaffOrAbove calls Stripe + writes a transaction audit record
POST /api/user-credit-balances / PUT /api/user-credit-balances/[id] keep isAdmin (decided) PUT no longer overwrites a balance — the balance field was removed; it only edits expiry. Raw balance overwrite is gone. POST (grant new balance) stays admin-only, NOT exposed via the staff MCP.
POST /api/user-credit-balances/[id]/adjust isStaffOrAbove atomic delta adjust (add/deduct) that mutates the balance and writes a STAFF_ADJUSTMENT ledger entry in one DB transaction — the audited replacement for the raw overwrite; safe for staff, MCP-exposable later

Decided (2026-05-31): the raw credit-balance write stays admin-only. adjust_member_balance covers only the safer, audited Stripe customer-balance path (downscoped to staff).

Updated (2026-07-01): the raw credit-balance overwrite (PUT … balance) was eliminated in favor of the atomic, always-audited POST /user-credit-balances/[id]/adjust (add/deduct delta + ledger entry in one transaction, isStaffOrAbove, works for user and org balances). The dangerous unaudited path the earlier decision guarded no longer exists; a future MCP tool can expose the /adjust endpoint directly without the admin gate.

Tool design conventions

  • Annotations: read tools readOnlyHint: true; Phase 2/3 destructiveHint: true so the host prompts for consent.
  • Preview-then-confirm for money/access (Phase 3): the model must first call a read/preview that surfaces the concrete effect (current balance, what the cancellation does), then call the action tool with an explicit confirm: true argument. Prevents one-shot accidental execution beyond the host's own consent gate. cancel_lockout can use the existing preview-transfer shape where applicable.
  • Errors: a non-2xx from the API surfaces as a clean tool error (the API already returns typed 4xx). The proxy never swallows or reinterprets business errors.
  • Structured output for read tools (typed results, not text-blobbed JSON).

Drift / maintenance

Curated hand-written tools (not OpenAPI-generated — the catalog is small and intentionally a safe subset). Note: an OpenAPI spec (packages/api-spec-fixed.json) and an orval-generated client (@mg/api-client) do exist, but both are unmaintained and incomplete (last touched Feb 2026; missing token-exchange, staff/search, stripe-balance, user-credit-balances, …) — so the MCP deliberately does not depend on the generated client; each tool derives its contract from the live route code. A trustworthy code→spec generation pipeline is a separate, unstarted initiative. Guard against API drift with:

  1. Monorepo CI contract tests — each tool exercised against the real API (test DB) on every PR; an endpoint change that breaks a tool fails CI before merge. This is the highest-leverage practice and is free from co-location.
  2. Typed responses / typed API client — contract changes break the MCP at compile time.

Revisit OpenAPI generation (zod-to-openapiopenapi-mcp-generatoroasdiff) only if the catalog outgrows ~15–20 tools.

Deployment

  • apps/staff-mcp → Docker image, container bound to 127.0.0.1:<port>, restart: unless-stopped, host path bind-mounted at /tokens (OAuth token store — skipping the mount reintroduces the restart-severs-all-sessions bug).
  • Add staff-mcp.metrognome.com → localhost:<port> to /etc/cloudflared/config.yml ingress (mirror the GW + Metabase entries).
  • Env: MG_MCP_CLIENT_ID / MG_MCP_CLIENT_SECRET (token-exchange Basic creds), MG_API_BASE_URL, Google OAuth client id/secret + JWT signing key (FastMCP OAuth proxy).
  • Register the MCP as a claude.ai custom connector (OAuth URL); auto-install for staff via the mg-app plugin per the ai-workspace spec.

Security

  • OAuth 2.1 (Google) at the edge; OBO token-exchange downstream — no token passthrough.
  • Every action attributed to the real staffer; delegations capped to STAFF (admins act as staff, never with admin claims).
  • Destructive tools require host consent + (money/access) explicit confirm.
  • Secrets in homelab env only, never committed; client secret rotered via the provisioning script.

Out of scope

  • Non-staff tools, bulk/migration operations, member-facing surfaces.
  • OpenAPI-driven tool generation (until the catalog grows).
  • The token-exchange primitive itself (separate, shipped spec).

Cross-system dependencies

  • token-exchange — the OBO primitive this consumes; sets oidcAudience.
  • ai-workspacemg-app plugin distributes this connector to staff.
  • API domains: users, reservations, lockouts, checkout, credits, payments, physical-access.