Skip to main content
Glama
dragosh29

Gecko Engage MCP Server

by dragosh29

Gecko Engage MCP server

An MCP server that lets Claude, ChatGPT and other MCP clients work with a Gecko Engage account, the student recruitment and engagement CRM used by universities and colleges: events and their sessions, bookings (attendances), forms and form responses, contacts (prospective students), labels and campaigns, and (when enabled) registering a contact for an event and adding a label to a contact. It is built from Gecko's public developer documentation: the Engage API OpenAPI 3.0.3 document at docs.geckoengage.com/openapi/engage-openapi.yml and the authentication guide at docs.geckoengage.com/docs/authentication.

Once it's connected, someone in the admissions or recruitment team can ask things like:

  • "How many people are booked on the Autumn Open Day, and how many are on the waitlist?"

  • "Which sessions does the open day have, and when?"

  • "How many responses has the Undergraduate Enquiry form had?"

  • "Find Priya Shah. Which events has she booked, and did she attend?"

  • "How did last week's WhatsApp open-day reminder do: delivered, read?"

  • With writes enabled: "Book Sam Evans onto the Spring Open Day." / "Add the Scholarship interest label to Sam."

Tools

Tool

What it does

API calls

whoami

The Gecko user the token belongs to, the token's region and expiry times (never the token), and which API scopes it has.

GET /auth/user, GET /auth/scopes

list_events

Events, sessions and session times with schedule, delivery method, capacity, tags (asked for with event_rfields=tags) and the number of attendances and responses. Filters keyword, status, type, category_id, delivery_method, parent_id.

GET /events

get_event

One event with its description as plain text, location, categories, tags, sessions and session times, and counts.

GET /events/{id}

list_attendances

Bookings for an event and/or a contact, with status, guest count and a count per status. Filters event_id, contact_id, status.

GET /attendances

list_forms

Forms with module, group, published, expired and full flags and their number of responses. Filters keyword, module, published, group.

GET /forms

list_form_responses

Form submissions with form, contact ID, draft or completed, labels (names, and IDs asked for with response_rfields=label_ids) and time. Filters form_id, label, module, response_keyword. As the API does by default, quarantined responses and responses to application-module forms are left out; module=application lists the application responses instead. The answers are not fetched.

GET /responses

search_contacts

Find contacts by keyword, label, attendance_event_id or email.

POST /contacts/search (a documented read)

get_contact

One contact by numeric ID or ULID: name, labels, preferred language, counts; contact fields only on request.

GET /contacts/{id}

list_labels

Labels with their IDs. Filter keyword.

GET /labels

list_campaigns

Campaigns with channel, status, schedule and subscribers. Filters keyword, status, module.

GET /campaigns

get_campaign_stats

Delivery and read stats for a WhatsApp broadcast campaign over a date range, in total and per period. The days are sent as YYYY-MM-DD 00:00:00 and YYYY-MM-DD 23:59:59, so the last day is included, as in the spec's example.

GET /campaigns/{id}/stats

register_contact_for_event

Books a contact onto an event or its waitlist. Because the endpoint is "create or update", it first looks up the contact's attendances on that event, deleted ones included (trashed=1), and refuses if there is any. The check is a separate read, not atomic with the booking: two calls for the same contact and event at the same moment could both pass it. Writes only.

GET /attendances, POST /attendances

add_contact_label

Adds an existing label to a contact, keeping their other labels. Writes only, marked destructive (see Safety defaults).

GET /labels, GET /contacts/{id}, POST /contacts/{id}/replace_labels

Not covered on purpose: everything else in a 458-operation API, including conversations and chat, calls, messages and sending email or SMS, workflows, imports and exports, files and every download (iCalendar files, Wallet passes, conversation exports), payments and transactions, users, integrations, deleting or merging anything, and the separate Portal API.

Related MCP server: NewZapp MCP server

Setup

Requires Node 18 or later.

npm install
npm run build

You need an API token for your Gecko account. As the authentication guide describes: ideally sign in as a dedicated API user in an "API Users" group with only the permissions needed (for this server's read tools, viewing events, attendances, forms, responses, contacts, labels and campaigns), go to Security Preferences -> Active Sessions -> Create New API Token, pick the profile and permissions, and copy the Access and Refresh tokens.

How the server uses them, following the guide:

  • The Access token is sent as Authorization: Bearer … to the regional API named in its region claim: eu is https://api-eu.geckoform.com, us-e is https://api-us-e.geckoform.com, ca is https://api-ca.geckoform.com. The server refuses to start if the claim is missing or names another region, unless GECKO_BASE_URL is set.

  • Access tokens last about a day. When the token's exp claim is less than a minute away, or the API answers 401, the server calls GET https://account-api.geckoengage.com/tokens/refresh with the Refresh token as the Bearer token and switches to the new Access, ID and Refresh tokens it gets back. Calls that need a refresh at the same moment share one.

  • Refresh tokens can only be used once, and the new tokens are kept in memory only. After the server has refreshed once, the Refresh token in your configuration is spent: when the server restarts, it cannot renew its tokens, and you need to create a new API token and update both variables. (The refresh token itself lasts about 30 days.) The server logs a warning to stderr each time it refreshes.

Claude Desktop: add this to claude_desktop_config.json:

{
  "mcpServers": {
    "geckoengage": {
      "command": "node",
      "args": ["/absolute/path/to/geckoengage-mcp/dist/index.js"],
      "env": { "GECKO_ACCESS_TOKEN": "your-access-token", "GECKO_REFRESH_TOKEN": "your-refresh-token" }
    }
  }
}

Claude Code:

claude mcp add geckoengage -e GECKO_ACCESS_TOKEN=your-access-token -e GECKO_REFRESH_TOKEN=your-refresh-token -- node /absolute/path/to/geckoengage-mcp/dist/index.js

Variable

Required

Meaning

GECKO_ACCESS_TOKEN

yes

The Access token, sent as a Bearer token. A value pasted with its Bearer prefix is accepted.

GECKO_REFRESH_TOKEN

recommended

The Refresh token, used once to renew the tokens (see above). Without it the server works until the Access token expires and then says so without making a request.

GECKO_ALLOW_WRITES

no

true to register register_contact_for_event and add_contact_label. Off by default.

GECKO_BASE_URL

no

Overrides the API host taken from the token's region. Used by the tests. Must not contain a username or password.

GECKO_TOKEN_URL

no

Overrides the refresh endpoint, https://account-api.geckoengage.com/tokens/refresh. Used by the tests. Same rule.

Safety defaults

  • Read-only unless GECKO_ALLOW_WRITES=true. Read tools carry the MCP readOnlyHint annotation. The only POST a read tool makes is POST /contacts/search, which the spec documents as a search returning contacts. register_contact_for_event is annotated as a non-destructive write. add_contact_label is annotated destructive, because the API has no "add one label" call: the tool reads the contact's labels and sends the whole set back through "replace the contact's labels". A label someone else adds or removes in between can therefore be overwritten. It sends nothing when the contact already has the label, and refuses an unknown label ID.

  • Gecko's contacts are prospective students, many of them under 18. By default a contact, attendee or response is identified by ID and name only. Email addresses (of contacts, attendees, responses and the API user) are only returned when a tool is called with include_contact_details=true. A contact's field values, which hold things like phone numbers, addresses, dates of birth and schools, are not even requested from the API unless get_contact is called that way. Form answers are never requested.

  • Never returned, even on request: payment data on bookings (payment_status, payment_transaction_id, payment_url, payment_response_id); contact fields whose label, tag or type looks like bank, card or payment data (the pattern covers bank, IBAN, BIC, SWIFT, sort code, account number, card, CVV/CVC, payment and expiry; the tests exercise a bank account field and a card number field); and the personal links Gecko issues for a student, which open their portal or booking without signing in (portal_url, qr_url, short_qr_url, events_page, available_auth_methods, and a booking's rsvp, ical, passbook and video_page). No file is ever downloaded.

  • In free text (names, event titles, internal titles, countries, tags and descriptions, form names and groups, label names, campaign titles and descriptions, and Gecko's own error messages) email addresses are replaced with [email redacted] and phone-number-like sequences with [phone redacted] by default. The phone match is a heuristic: it covers international numbers written with + or 00 (including the +44 (0)7700 … form), UK numbers with a bracketed area code such as (020) 7946 0958, and UK-style 0… numbers of 9 to 11 digits with spaces, dots or hyphens between groups. Other digit strings of that shape that start with 0 are redacted too. Tools that return free text take include_contact_details to turn this off. Form names and groups, label names and campaign titles and descriptions are always redacted (those tools have no switch). The tests cover names, an event's internal title, country and description, a form name and group, a label name, campaign descriptions and Gecko's error messages.

  • Tokens never appear in tool output or error messages: every error message is checked against every token the server has held, including the Access, ID and Refresh tokens each refresh returns, and a match is replaced with [redacted]. From an error response only Gecko's JSON message fields (message, details, messages, errors) are passed on. A non-JSON body, such as a gateway page, is never quoted.

  • IDs are checked before any call is made. Every ID on the endpoints used here is an integer in the spec, so tools take positive whole numbers. The one exception is get_contact, which, like GET /contacts/{id}, also takes the contact's 26-character ULID. Stats dates must be real YYYY-MM-DD dates with from not after to, and list_attendances needs an event or a contact ID. Free-text filters are trimmed, and one that is empty or only spaces is refused. A form group that starts with @ and contains | is refused, because Gecko would read it as several groups.

  • Gecko documents no rate limit and no 429 response. Requests are spaced 250 ms apart (about four per second). A 429 is retried at most twice for any method, on the assumption that a rate-limited request was not processed, waiting for Retry-After (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when the header is absent or unreadable). Each wait is capped at 10 seconds, so one request waits at most 20 seconds in all. A tool call that makes several requests (pages, lookups) can still run past the MCP client's default 60-second request timeout. If Gecko asks for a wait longer than the cap, the call gives up at once and the message says how long to wait.

  • 502, 503 and 504 are retried the same way for GET requests to the API only. When all three attempts fail, the error says the service may be unavailable and to try again in a few minutes. A POST is never retried after a gateway error, because it may already have been processed; the error says to check with the matching list or get tool first. The token refresh is never retried after a gateway error either: the Refresh token is single-use, and the refresh may already have happened.

  • A 200 whose body is not JSON (a proxy or a login page in the way) is reported as an error naming GECKO_BASE_URL, never as an empty list. A 401 says which variables to fix and where tokens come from, a spent or expired Refresh token says to create a new API token, a 403 says to check the token's scopes with whoami, and a 404 includes Gecko's message.

  • Lists are paged with the documented page and per_page (50 per page by default, at most 100; the spec documents no maximum). A call fetches up to max_pages pages (4 by default) and stops at the documented end, current_page reaching last_page, or at an empty page. Pages are always returned whole. When more remain, the result says complete: false with the next_page to continue from.

Tests

npm test

The test suite:

  1. Validates every fixture record against the component schemas in Gecko's published OpenAPI document (Events_EventObject, Attendances_Attendance, Contacts_Contact including included current values, Forms_Form, Responses_Response, Labels_Label, Campaigns_Campaign, Campaigns_CampaignStatsResponse, Auth_UserSummary, Auth_AuthScope) with Ajv and ajv-formats. OpenAPI 3.0's nullable is rewritten as a JSON Schema union first. A negative control checks that a wrong type is still rejected. The spec is downloaded from docs.geckoengage.com/openapi/engage-openapi.yml to spec.yaml on the first run.

  2. Starts a local mock of the API and of the refresh endpoint that serves those fixtures with the documented Laravel-style pagination (current_page, last_page, per_page, total; every page capped at 10 so the suite pages) and the documented filters, including the @a|b multi-value syntax and the comma-separated category_id. It checks Bearer tokens (a 401 in the documented shape for a token it did not issue), refreshes single-use Refresh tokens, returns 404s with the spec's example messages, and answers the first GET /forms with a 429. It follows the documented defaults the tools depend on: event tags only with event_rfields=tags, response label_ids only with response_rfields=label_ids, responses to application-module forms only with module=application, and soft-deleted attendances only with trashed=1. The stats range echoes the from and to it received. The mock's list, detail, write and error responses (200, 400, 401, 404) are validated against the response schemas the spec names for each operation. The request bodies it accepts are validated against the documented request schemas (the spec's own POST /attendances example among them). The refresh endpoint is not in the spec: its response is checked against a schema written from the guide's example refresh response, which the schema also accepts.

  3. Starts the built server and drives it over stdio with the official MCP client: 30 checks (32 in the whole suite, about 50 seconds). They cover:

    • tools/list and annotations, and every tool.

    • Each documented filter passed through exactly; paging 1, 2, 3 to last_page with requests at least about 250 ms apart; stopping at the default of 4 pages and continuing from next_page; an empty page ending a list; the search body repeated on every page; application-module responses left out without module=application and returned with it; the stats range sent with its end-of-day time.

    • Names without contact details by default and emails on request; payment fields and personal links never returned; contact fields not requested by default, and returned on request except the bank and card fields; emails and phone numbers in names, an internal title, a country, a description, a form name and group, a label name and campaign descriptions (including the +44 (0), bracketed, 00-prefixed and dot-separated phone forms) redacted.

    • The search and the two write bodies validated against the documented request schemas, with every key a documented property; an existing booking, and a soft-deleted one, refused without a POST; an existing label and an unknown label causing no POST.

    • IDs, a per_page above 100, impossible dates, reversed ranges, blank keywords and a group in the multi-value syntax refused before any request; keywords trimmed; the 404 and 403 messages.

    • The 429 retry in the seconds, fractional-seconds and HTTP-date forms and the 2 s then 4 s fallback without the header; giving up after three attempts, and at once above the cap; a wait of exactly the 10 s cap honoured; a 502 retried for GET; three 503s reported without the gateway page; a 502 on POST never retried and a 429 on POST retried once; a non-JSON 200 reported as an error; error bodies in all three documented shapes (message/details, messages, errors) passed on redacted, including a 400 that echoes the token and a contact's email and phone.

    • Tokens:

      • A token near expiry refreshed before the call with the Refresh token as Bearer, the new token reused, the warning written to stderr without any token, and an error echoing the new tokens scrubbed.

      • A spent Refresh token after a "restart" refused with instructions and no API call.

      • A revoked token refreshed once and the call retried; revoked again on the same server, the second refresh uses the Refresh token the first one returned, never the spent one.

      • A 401 that persists after a refresh reported after exactly one refresh.

      • A 429 from the refresh endpoint retried with the same Refresh token.

      • Three concurrent calls sharing one refresh.

      • An expired Access token without a Refresh token, and an expired Refresh token, reported without a request.

      • A wrong token (pasted with Bearer ) giving an actionable 401.

      • A 502 from the refresh endpoint not retried.

      • The region-to-host mapping.

      • Start-up refused for a missing or unknown region, an unreadable token, no token, or a username and password in GECKO_BASE_URL or GECKO_TOKEN_URL (without repeating the password).

    • The write gate with the variable unset and false.

    • That every request carried a token the mock issued (or the one deliberately wrong token) to a documented method and path with only documented query parameters, that the refresh used a Refresh token, and that the only POSTs were the search and the two writes.

Test tokens are unsigned JWT-shaped values built at run time from JSON, with markers like gecko-test-access-1-not-real. No token-like string is stored in the repository.

Status

This is a working prototype. It has not yet been run against the live API, because it was built without a Gecko account: no public trial or sandbox was found, and Gecko sells through demos. Everything below is taken from the published spec and authentication guide and should be confirmed on a real account:

  • Tokens. That the Access token carries exp and a region claim with the values eu, us-e or ca, as the guide says.

  • Token refresh. That GET /tokens/refresh answers with AccessToken, IdToken and RefreshToken as in the guide's example; the example's ExpiresIn looks like a Unix time rather than a duration, and the server ignores it and reads exp from the token instead. Also what status and body it returns for a spent or expired Refresh token (undocumented; the mock uses 401 with a message).

  • Expired tokens. That an expired Access token gets a 401 from the API; the spec documents 401 for "authentication is required or the supplied token is invalid".

  • Paging. The largest per_page the API accepts and what it does above it (the spec gives a minimum of 1 and no maximum; the server asks for 50 by default and at most 100). That last_page is present on every list, as the collection schemas require.

  • Sort order. The default sort order of every list; the server returns whatever order the API uses.

  • Filter semantics. How keyword on events, forms, labels and campaigns, and response_keyword on responses, match. Whether email on POST /contacts/search is an exact or partial match (the spec types it as a string without saying). Whether several status, type, delivery_method, module, form_id, label and contact_id values really combine as "any of" with the @a|b syntax, while category_id is comma-separated as documented.

  • Counts. That counts=attendances,responses on events, counts=responses on forms, counts=subscribers on campaigns and counts=attendances,responses on contacts add attendances_count, responses_count and subscribers_count fields, as the schemas and the events list example suggest. Whether a form's response count includes responses that GET /responses leaves out by default (application-module and quarantined ones); the mock counts them all.

  • Extra fields. That event_rfields=tags adds tags to events and response_rfields=label_ids adds label_ids to responses, as the parameter descriptions say.

  • Contact fields. The shape of current_values with include=current_values.field: the server shows each value's safe text (falling back to value) under its field's label, as in the spec's example. The bank and card filter works on field labels, tags and types. Whether an account's real field names are caught by it needs checking, and a field named differently would be returned with include_contact_details.

  • POST /attendances. What "create or update" does with a soft-deleted attendance (the tool refuses in that case, so this is not exercised). Whether a booking made through the API sends the event's confirmation email or runs its workflows (not documented). That status: "yes" creates a Registered (10) attendance and "waitlist" a Waitlisted (50) one. The capacity and guest-count errors, which the spec documents only by the example "Guest count must not be negative.".

  • POST /contacts/{id}/replace_labels. That it accepts a plain list of existing label IDs; the spec says "Items may be existing label IDs or label objects".

  • GET /campaigns/{id}/stats. What it answers for a campaign that is not a WhatsApp broadcast; the spec documents the endpoint for WhatsApp only. That from and to sent as YYYY-MM-DD HH:MM:SS strings are accepted ("a parseable date/time string"), and in which timezone they are read (the example response carries a timezone); if it is UTC, the edges of the range can be off by the account's UTC offset.

  • Error wording. The wording of Gecko's error messages, and whether any of them echo request data; the server redacts and scrubs them regardless.

  • Rate limits. How many requests per second the API tolerates; nothing is documented, so the 250 ms spacing is a guess on the polite side.

Going to production

This version runs locally over stdio with a token the user creates by hand. It keeps rotated tokens in memory, so a restart needs a new API token. For admissions teams to connect from claude.ai or ChatGPT without handling tokens, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by Gecko, so each user's own permissions apply. Tokens would be stored in a secret store between refreshes, as the guide suggests. After that comes a listing in the Claude and ChatGPT connector directories. Further write tools (updating a booking's status after check-in, working with conversations) can follow once they can be tested on a real account.

Licence

MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Enables Claude, ChatGPT and other MCP clients to read an Amiqus ID account—clients, onboarding records and steps, check results, templates, case status counts and webhooks—and, when writes are enabled, create records.
    9
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables Claude, ChatGPT and other MCP clients to read NewZapp account details, campaign reports, open heatmaps, contact groups and contact counts, and to search contacts with personal data withheld by default; when writes are enabled, it can also create and update contacts.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables Claude, ChatGPT and other MCP clients to search a membership database's people and organisations, read a contact's summary and membership history, list events with ticket types and attendance lists, read invoices, and find and count segments. When writes are enabled, it also lets clients record who attended an event.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Lets MCP clients such as Claude and ChatGPT read a hospitality guest CRM: the venues in an organisation, guest profiles with their tags and orders, deals, and which guest owns a Wi-Fi device. When writes are explicitly enabled it can also add a tag to a guest.
    8 npm
    MIT