Skip to main content
Glama
dragosh29

NewZapp MCP server

by dragosh29

NewZapp MCP server

An MCP server that lets Claude, ChatGPT and other MCP clients work with a NewZapp email marketing and internal communications account: the account and its licence, campaign reports, open heatmaps, contact groups, contact counts and a contact search that withholds personal data by default, and (when enabled) creating and updating contacts. It is built from NewZapp's public API documentation only: the OpenAPI 3.0.1 document "NewZapp API" v3 at https://my.newzapp.co.uk/swagger/v3/swagger.json, which is what the ReDoc page at https://my.newzapp.co.uk/api-docs renders.

Once it's connected, a comms team can ask things like:

  • "How did last week's staff newsletter do: opens, clicks, unsubscribes?"

  • "Which of our September campaigns had the best click-to-open rate?"

  • "When do people actually open our emails? Show me the busiest hours."

  • "How many contacts in the Managers group have unsubscribed or bounced?"

  • "How many people opened the newsletter but didn't click?"

  • With writes enabled: "Add our new starter Nia Jones to All staff, with Department set to Parks."

Tools

Tool

What it does

API calls

get_account

Account name and company, licence (product, subscriber and user limits, internal comms flag), double opt-in setting, and the custom contact fields defined on the account. The SMTP username is never returned.

GET /api/account

list_campaigns

Campaign reports: name, subject, status, send time, groups and topics, recipients selected and sent, unique and total opens and clicks, bounces, failures, unsubscribes, feedback, channel totals and read times, with open, click and click-to-open rates computed from those counts. Passes Search, FromDate, ToDate, ModifiedFromDate, ModifiedToDate, Status, TopicIds, OrderBy and IsDescending through under their documented names; pages with Skip/Take.

GET /api/campaigns

get_campaign

One campaign as NewZapp returns it. The spec gives this response no schema, so the record is passed through an allowlist (see Safety defaults): numbers, true/false values and top-level text by default. For the documented report fields, formatted, use list_campaigns.

GET /api/campaigns/{id}

get_campaign_summary

NewZapp's "summary of recipient action data" for one campaign, optionally for one group (groupId). No schema is documented; passed through the same allowlist, so per-recipient rows are withheld by default.

GET /api/campaigns/{id}/summary

get_campaign_heatmap

Opens per hour of the day and day of the week, for one campaign (id) or a date range (from, to), with the busiest slots and totals per day and per hour. Day 1 is Sunday, as the spec says.

GET /api/campaigns/heatmap

list_contact_groups

Contact groups with their contact and suppressed counts, type, tags, automations and segments. Passes type, searchValue and tagIds through.

GET /api/groups

count_contacts

How many contacts match any of the documented filters: GroupId, Search, Suppressed, Ungrouped, Unsubscribed, Bounced, IncludeSuppressed, CampaignId, LinkId, Sent, Delivered, Failed, Opened, Clicked, Device, Client, Condition, SendDateTime and the Filter model. Returns a number only, though a count of 1 for a search on one email address still confirms that the person is a contact.

GET /api/contacts/count

search_contacts

Contacts matching the same filters, sorted by Field and IsDescending, paged with Skip/Take. Only IDs, subscription flags and dates by default.

GET /api/contacts

create_contact

Creates one contact, optionally in groups and with custom field values. Refuses locally if a group or custom field ID is not on the account, or if a contact with the same email already exists. Only registered when writes are enabled.

GET /api/account, GET /api/groups (only when those IDs are given), GET /api/contacts, POST /api/contacts

update_contact

Changes fields of one contact, adds it to groups, sets custom field values, or marks it unsubscribed. Writes only.

GET /api/account, GET /api/groups (only when those IDs are given), GET /api/contacts/{id}, PUT /api/contacts/{id}

Array parameters are sent as repeated keys (TopicIds=7&TopicIds=99), as the GET /api/contacts description says. The Filter model is sent exactly as that description shows it: five parameters per filter, filters[i].condition, .field, .operator, .value and .order, with condition=null on the first filter.

Not covered on purpose: every DELETE endpoint (campaigns, contacts, groups, pages), POST /api/groups, POST /api/custom-fields, POST /abuse-complaint, GET /api/campaigns/{id}/html (the campaign's HTML), the landing page endpoints GET /api/pages and /api/pages/{id}, and GET /api/custom-fields (whose 200 has no schema; the custom field definitions come from GET /api/account, which has one). The API documents no endpoint that sends a campaign, and no tool here sends one.

Related MCP server: Amiqus MCP server

Setup

Requires Node 18 or later.

npm install
npm run build

Create an API key in NewZapp as the spec describes: click your profile image, go to Account Settings > API Integration and create a key. The server sends it as the X-Api-Key header. NewZapp's help centre says API integration "is not covered under general NewZapp support".

Claude Desktop: add this to claude_desktop_config.json:

{
  "mcpServers": {
    "newzapp": {
      "command": "node",
      "args": ["/absolute/path/to/newzapp-mcp/dist/index.js"],
      "env": { "NEWZAPP_API_KEY": "your-key" }
    }
  }
}

Claude Code:

claude mcp add newzapp -e NEWZAPP_API_KEY=your-key -- node /absolute/path/to/newzapp-mcp/dist/index.js

Variable

Required

Meaning

NEWZAPP_API_KEY

yes

Your API key, sent as the X-Api-Key header.

NEWZAPP_ALLOW_WRITES

no

true to register create_contact and update_contact. Off by default.

NEWZAPP_BASE_URL

no

Defaults to https://my.newzapp.co.uk. Used by the tests. The spec has no servers block; the default is inferred from the host that serves the spec and the docs page.

Safety defaults

  • Read-only unless NEWZAPP_ALLOW_WRITES=true. Read tools carry the MCP readOnlyHint annotation. create_contact is marked non-destructive and non-idempotent; update_contact is marked destructive (it overwrites the fields you name) and idempotent. No tool deletes anything or sends a campaign.

  • Contacts are people. By default search_contacts returns only each contact's ID, the unsubscribed, confirmed, suppressed and bounced flags, and the subscribe, unsubscribe, confirm and suppress-until dates. Email address, title, first and last name, company, job title, mobile and telephone numbers, date of birth and postal address are only returned with include_contact_details=true. A match on a search term or a Filter still confirms that such a contact exists, even when its details are withheld; the same holds for count_contacts, which returns a number only. create_contact does not echo the email address back (it returns the new ID and groups, and says so if NewZapp stored the address differently).

  • In free text (campaign names, subjects and feedback, group, segment, topic and tag names, group descriptions, the account name and company, NewZapp's error messages) email addresses are replaced with [email redacted] and phone-number-like sequences with [phone redacted] by default, with an email pattern and a phone-number heuristic (international numbers with + or 00, bracketed UK area codes, and UK numbers starting with 0): international numbers written with + or 00, UK numbers with a bracketed area code, and UK-style 0… numbers of 9 to 11 digits with spaces, dots or hyphens. Other digit strings starting with 0 are redacted too, while numeric IDs and timestamps are left alone. include_contact_details=true returns the text as stored on the tools that have it (list_campaigns, get_campaign, get_campaign_summary, list_contact_groups, search_contacts); get_account always redacts.

  • get_campaign and get_campaign_summary return records whose shape NewZapp does not document, and the summary is documented as "recipient action data", so per-recipient rows are the expected case. By default both go through an allowlist: numbers, true/false values and nulls are kept at any depth; text is kept only directly on the top-level record (with emails and phone numbers redacted as above); a list holding anything other than numbers, true/false values and nulls (per-recipient rows under any key name, but also the campaign's groups, topics, feedback and channel totals) is withheld whole; text nested deeper is withheld. On top of that, any key whose name suggests contact, identity or sender data is withheld whatever its value, a number included. Keys are matched in any spelling (emailAddress, email_address and emailaddress alike) on these words: mail, phone, mobile, fax, address, postcode, postal, birth, name, location, salary, wage, user agent, custom field, job title, sender, author, owner, created/modified/updated by, reply-to, and, as whole words, tel, zip, city, county, country, dob, ip and pay, plus keys named exactly from, to, cc, bcc, user or person. The campaign's own top-level name is the one exception. A key naming people (contacts, recipients, subscribers, people, person, members, users) is withheld when it holds a list or object. A mobile key is withheld when it holds a string or a number (a number there could be a phone number, so even a count by device is withheld), but kept when it holds an object such as the read-time breakdown by device. Everything withheld is listed by path under withheld_fields (at most 100 paths). Keys that suggest bank, card or payment data (bank, IBAN, sort code, card, card number, PAN, BIN, last4, CVV, payment, account number) or credentials (password, secret, token, API key, SMTP username) are never returned, not even with include_contact_details, and are listed under never_returned. include_contact_details=true returns the rest as stored. Strings longer than 1,000 characters are cut, and nesting deeper than 20 levels is replaced by a placeholder. The default output can still carry personal data in a top-level text field under an innocuous key (only emails and phone numbers are redacted there) or as a bare number under an innocuous key.

  • Nothing is ever downloaded, and the campaign HTML endpoint is not used.

  • Tool arguments are checked before any call is made. An unknown argument name is rejected rather than ignored, so a misnamed filter (email instead of search or filters, say) cannot silently turn a count or a search into one over the whole account. IDs: every path and ID parameter is an int32 in the spec, so IDs must be whole numbers from 1 to 2147483647. Dates must be real dates in YYYY-MM-DD or ISO 8601 date-time form (passed to NewZapp as given, although the spec types them as date-times; see Status). Parameters whose values the spec does not list (Status, OrderBy, group type, Device, Client, Condition, Filter operators) accept letters, digits, spaces, _, . and - only; Filter field names and the sort field accept letters, digits and _.

  • Writes never change subscription status except in one direction: update_contact can mark a contact unsubscribed and cannot re-subscribe one, and create_contact sends no subscription flag. isUnsubscribed: false is never sent. Neither tool sends the documented deleted field, and suppressUntilDate is only ever sent back unchanged (next point).

  • create_contact checks the account's custom fields (GET /api/account) and groups (GET /api/groups) when you pass their IDs, then looks for an existing contact with the same email (the Filter model with emailaddress and contains, compared exactly and case-insensitively on the results) and refuses to create a duplicate, pointing to update_contact.

  • update_contact reads the contact first and sends back the full set of documented profile fields it has, its custom fields and its group IDs, with your changes applied. The spec does not say whether PUT /api/contacts/{id} replaces the whole contact or only the fields sent, so the body also carries isUnsubscribed: true when the contact is already unsubscribed and its current suppressUntilDate when it has one. Under either reading, then, the documented profile fields you did not name, the custom fields, the group memberships, an unsubscribe and a suppression date keep their current values (checked against the mock only; see Status). Group membership is only added to: the body carries the existing group IDs plus the new ones. A contact marked isDeleted is refused without a write.

  • NewZapp documents no rate limit (neither the spec nor the "API Integration" help article mentions one). Requests are spaced 250 ms apart (about four per second). A 429 is retried at most twice for any method, including POST /api/contacts, on the assumption that a rate-limited request was not processed (see Status). The retry waits for Retry-After (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when the header is absent or unreadable). A single wait is capped at 10 seconds: if NewZapp asks for a longer one the request gives up at once and the message says how long to wait. Because one tool call can make many requests (a listing pages up to 40 times, create_contact makes up to 7), each tool call also has one 40-second budget shared by all its requests, throttle and retry waits included, to stay under the MCP client's default 60-second request timeout: a retry wait that would end past the budget is not started, a request still running when it runs out is aborted, and a POST or PUT is not started with less than 10 seconds left (nothing is written). A listing that runs out of budget after at least one page returns the records it has, marked incomplete, with the skip to continue from.

  • 502, 503 and 504 are retried the same way for GET only; when all three attempts fail the error says the service may be unavailable, without the gateway's HTML. A POST or PUT is never retried after a gateway error, because it may already have been processed; the error says to check with search_contacts before repeating it.

  • Paging continues from the first record not returned, even when NewZapp sends a page longer than the Take asked for. A 200 whose body is not JSON (a proxy or login page in the way) is reported as an error naming NEWZAPP_BASE_URL, never as an empty list. An empty 200 body is accepted as success (the PUT documents no response body).

  • A rejected key (401 or 403) produces a message that says which variable to fix, where the key is created, and to check that the API is enabled on the plan. Error text quoted from NewZapp, and the start of a non-JSON 200 body, is scrubbed of the configured API key and redacted like other free text. That redaction covers email addresses and phone numbers only: a name, date of birth or postcode in a NewZapp error message would be passed through.

Tests

npm test

The suite takes about 70 seconds (about 30 of them in the time-budget check, which waits out real Retry-After delays) and runs offline once spec.json is present (it is downloaded from my.newzapp.co.uk/swagger/v3/swagger.json on the first run when missing).

  1. Checks the spec itself: OpenAPI 3.0.1, 22 operations, no servers block, no securitySchemes, the X-Api-Key instruction in info.description, every operation this server calls, the exact query parameter names of each (which the tools pass through), the documented Filter model encoding, that each used operation documents only a 200 response, which of them have no response schema (GET /api/campaigns/{id}, GET /api/campaigns/{id}/summary, PUT /api/contacts/{id}), that the spec carries no JSON examples, and that the mock's key (nz-test-key-not-real) is obviously fake and not in the spec. Then validates every fixture record with Ajv against the published component schemas (AccountDTO, CampaignReportDTO, CampaignHeatmapDTO, GroupViewModel, ContactDTO, ContactDetailsDTO), with negative controls showing that undeclared keys, ids above int32 and non-date-times are rejected. One adjustment is made for Ajv: CustomFieldDTO.value is {"nullable": true} without a type, which Ajv refuses; a schema without a type already accepts any value including null, so that nullable is dropped. Every other schema is used as published.

  2. Starts a local mock of the API under /api that serves the fixtures with the X-Api-Key header, Skip/Take paging over bare JSON arrays, the documented filters it implements (Search, Status, TopicIds, FromDate/ToDate on campaigns; GroupId, Search, Unsubscribed, Bounced, Suppressed and the Filter model on emailaddress contains for contacts; type, searchValue, tagIds for groups; the rest are recorded but do not filter), 401 without the right key, 404 for unknown IDs, and a one-off 429 with Retry-After. The mock's list, record, count, contact-detail and create responses are validated against the documented response schemas. NewZapp documents no response schema and no example for the campaign detail or the campaign summary, so their shape is unconfirmed: the mock serves the campaign's CampaignReportDTO record as its detail (an assumption, checked against that schema once the invented keys are removed) with sender, author, reply-to, a scalar mobile, custom field, HTML and SMTP password keys invented to exercise the pass-through, and a summary whose shape is invented (aggregate counts, per-recipient rows under several key names with lower-case concatenated keys, a phone number stored as a number and addresses in free text, taken from a reviewer's probe that got past an earlier version) and not validated against anything. The status codes and bodies of errors are assumptions too (see Status).

  3. Starts the built server and drives it over stdio with the official MCP client: 30 checks (33 in the whole suite) covering tools/list and every tool's annotations, unknown argument names rejected on every tool without a request, the 250 ms spacing between paging requests, every read tool, Skip/Take paging across three pages stopping at a short page and across three full pages stopping at an empty fourth, max_results with continuation by skip, a page longer than the Take asked for continued from the first record not returned, an API that ignores Skip detected instead of looped, heatmap cells outside the documented ranges ignored, a fractional count refused, every documented filter of list_campaigns, list_contact_groups, get_campaign_heatmap, get_campaign_summary, count_contacts and search_contacts passed through under its documented name (the Filter model as filters[i].* with condition=null first), redaction and withholding by default and their return on request (contacts, campaign subjects and feedback, group descriptions, the pass-through records under the allowlist with sender, author, contact and custom field keys and every list of records withheld, the per-recipient probe rows and contact keys in lower-case spellings withheld, a mobile object kept and a mobile string withheld, credentials never returned even on request, long HTML cut), create_contact not echoing the email, the SMTP username never returned, create_contact and update_contact request bodies validated against the documented request schemas (CreateOrUpdateContactApiDTO, CreateOrUpdateContactDTO) and asserted field by field, the duplicate and reference checks refusing without a write, an update to an unsubscribed and suppressed contact sending isUnsubscribed: true and the suppression date back, a contact marked isDeleted refused after one read, unsubscribe allowed and re-subscribe refused, the write gate with the variable unset and set to false, IDs, dates and parameter values refused before any call, the 404 message, the 401 message after exactly one request, the 403 message, the 429 retry waiting for Retry-After in the seconds, fractional-seconds and HTTP-date forms, the 2 s then 4 s fallback when the header is missing or unreadable, giving up at once on a wait above the cap and after three attempts on a persistent 429, a 429 on POST retried once, a 502 on a GET retried, a GET failing three times with 503 reported without the gateway HTML, a 502 on POST and a 504 on PUT never retried, a paged call slowed by a 429 before every page ending inside the 40-second budget with the records read so far and where to continue, a non-JSON 200 reported as an error (an email and phone number at the start of it redacted), an error body echoing the key and an email scrubbed of both, and that every request carried X-Api-Key and no Authorization header, used a documented method and path with documented parameter names only, and that every operation the server uses was exercised.

Two time-budget paths are not in the suite, because each needs a request held open for 30 to 40 seconds: aborting a request NewZapp never answers, and refusing to start a POST with less than 10 seconds of budget left. Both were checked once with a throwaway local server (no NewZapp host involved): get_account against a server that never answered returned the budget error after 40.0 s, and create_contact whose duplicate-check GET took 31 s returned "nothing was written" with no POST sent.

Status

This is a working prototype. It has not been run against the live API, because it was built without a NewZapp account (no self-serve trial was found). No request with credentials was made to NewZapp; the only requests to NewZapp's hosts were unauthenticated fetches of public pages: the spec, the API docs page, the help centre, and the pricing page (which refused the request). Everything below should be confirmed on a real account:

  • The base URL https://my.newzapp.co.uk, inferred because the spec has no servers block.

  • What a missing, wrong or unauthorised key gets back. The spec documents no error responses at all; the server treats 401 and 403 as a key problem, and the mock answers 401 with an empty body.

  • The status codes and bodies of other errors: 404 for an unknown ID and the 400 shape are assumptions (the mock uses ASP.NET Core problem details, which the spec's text/json and application/*+json content types suggest). The server reads title, detail, message, error and errors from whatever comes back.

  • The response shapes of GET /api/campaigns/{id} and GET /api/campaigns/{id}/summary (no schema, no example). The server does not depend on any field name there. The default output of those two tools is an allowlist (numbers, true/false values, top-level text, minus contact-looking keys), which may withhold more than needed on a real record; check what a real record contains, in particular whether the summary lists recipients and under what keys, and whether any top-level text or bare number identifies a person.

  • Paging: the default and maximum Take, whether Skip/Take behave as offset and page size, and the end of a list. Nothing is documented: the server asks for 50 at a time and stops at an empty page or a page shorter than it asked for, so an API that silently caps Take below 50 would end a listing early. The default sort order of campaigns and contacts is not documented either.

  • Parameter name casing. The server sends the names as the spec lists them (Search, Skip, GroupId, and lower-case from, to, id, groupId, type, searchValue, tagIds), while the example in the GET /api/contacts description writes them in lower case (?skip=0&take=10&field=emailAddress…). The Filter model is sent in the description's lower-case filters[i].* form.

  • The Filter model: which field names and operators exist (the documentation shows only emailaddress, isconfirmed and contains, and says the full list is in the Contacts filter panel in NewZapp), whether the first filter's condition=null is needed as printed, and whether or works as a condition. What the separate Condition parameter does is not documented.

  • The values of Status and OrderBy on campaigns, type on groups, and Device and Client on contacts; none is listed. The fixture values ("Sent", "Draft", "Internal", …) are invented.

  • How Search matches (which fields, and whether several terms must all match), and what FromDate/ToDate, ModifiedFromDate/ModifiedToDate and the heatmap's from/to compare against, including time zones and whether the bounds are inclusive.

  • Whether the date parameters (FromDate, ToDate, ModifiedFromDate, ModifiedToDate, the heatmap's from and to, SendDateTime) accept a bare YYYY-MM-DD. The spec types them as format: date-time; the server passes a date-only value through unchanged, which the tests only check on the wire.

  • The date-time format in responses (the fixtures use Z-suffixed ISO 8601 because the schemas say date-time).

  • CampaignReportDTO semantics: how selected, sent, bounced and failed relate, and whether NewZapp's own open and click rates are computed the way this server computes them (unique opens or clicks divided by sent).

  • POST /api/contacts: whether the API itself rejects or merges a duplicate email, whether groupIds (or the separate groupId) is what adds a contact to groups, whether a custom field value can be sent as {id, value} without name and type, how dateOfBirth is stored (sent as YYYY-MM-DDT00:00:00Z), and whether creating a contact on an account with double opt-in switched on sends the person a confirmation email.

  • PUT /api/contacts/{id}: whether it replaces the whole contact or only the fields sent, whether groupIds replaces or adds to the memberships, whether isUnsubscribed: true unsubscribes as expected, whether sending an existing suppressUntilDate back is accepted unchanged, and what the 200 body contains (the mock sends none).

  • The duplicate check in create_contact relies on the Filter model with emailaddress contains; if that filter does not work as documented, the check could miss an existing contact.

  • A 429 on POST /api/contacts is retried on the assumption that a rate-limited request was not processed; confirm NewZapp never creates the contact before answering 429.

  • How many requests per second the API tolerates; nothing is documented, so the 250 ms spacing is a guess on the polite side. How long real requests take is unknown too: a long listing on a slow account can end early at the 40-second budget (it says where to continue).

Going to production

This version runs locally over stdio, with the account holder's own API key. For customers to connect from claude.ai or ChatGPT without handling keys, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by NewZapp, a run of the suite against a real account to settle the points above, and then a listing in the Claude and ChatGPT connector directories.

Licence

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

Related MCP Connectors

  • Zapier MCP connects AI tools like Claude, ChatGPT, and Cursor to over 8,000 apps and 30,000+ actions, enabling AI to perform real-world tasks such as sending messages, searching data, scheduling events, and updating records. It acts as a translator between AI tools and apps, handling authentication, rate limits, and retries automatically, transforming AI from a conversational tool into a functional extension of your business stack.

  • AXL MCP lets AI assistants create and manage landing pages, courses, email campaigns, CRM records, and marketing workflows inside AXL. Built for growing expert businesses, it turns chat requests into real work across sales, marketing, and course delivery. An AXL account is required. Sign in securely with OAuth 2.1. Website: https://axl.tech/developers/mcp . Setup guide: https://docs.axl.tech/mcp . Watch AXL in 77 seconds: pages, courses, CRM, and automation. Product overview: https://www.youtube.com/watch?v=jlhR9CafIww

  • Let Claude or ChatGPT search, read and send your WhatsApp messages over MCP. OAuth sign-in.

  • Read SMS, WhatsApp, email, contacts and audiences from your Bird workspace, plus safe CRM writes.

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Lets Claude, ChatGPT and other MCP clients read a SmartSurvey account's surveys, survey designs, responses, exports and folders, and — when writes are enabled — open or close a survey or send an existing invitation to one named recipient. It runs read-only by default, redacting respondent contact details and phone-number-like text unless contact details are explicitly requested.
    7
    MIT
  • 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 practice-management data including organization, clinicians, diaries, availability, bookings, patients, invoices, payments, staff tasks, services, and locations, and optionally create staff tasks, create bookings, and cancel bookings.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Lets MCP clients such as Claude and ChatGPT read a rota and time-and-attendance account, exposing venues, groups, shifts, absences and absence types, time entries, venue events and staff names through read-only tools that never return pay data.
    MIT