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 withoidcAudienceset. 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/apiover 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-mcpsetsdelegatedRoleCap: '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 on127.0.0.1:<port>,staff-mcp.metrognome.comingress). - Lives at
apps/staff-mcpin 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 isAdmin → isStaffOrAbove, 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/3destructiveHint: trueso 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: trueargument. Prevents one-shot accidental execution beyond the host's own consent gate.cancel_lockoutcan use the existingpreview-transfershape 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:
- 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.
- Typed responses / typed API client — contract changes break the MCP at compile time.
Revisit OpenAPI generation (zod-to-openapi → openapi-mcp-generator → oasdiff) only if the catalog outgrows ~15–20 tools.
Deployment
apps/staff-mcp→ Docker image, container bound to127.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.ymlingress (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-appplugin 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-workspace —
mg-appplugin distributes this connector to staff. - API domains: users, reservations, lockouts, checkout, credits, payments, physical-access.