Skip to main content
Glama
dragosh29

NewZapp MCP server

by dragosh29
README.md
# NewZapp MCP server

An [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT and other MCP clients work with a [NewZapp](https://newzapp.co.uk) 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.

## Setup

Requires Node 18 or later.

```bash
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`:

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

**Claude Code:**

```bash
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

```bash
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.