Skip to main content
Glama
Vojtaupan

instantly-ai-mcp

by Vojtaupan
README.md
# instantly-ai-mcp

An MCP server for the [Instantly.ai](https://instantly.ai) v2 REST API that encodes what the API
*actually does* instead of trusting what its docs say. Every quirk below was reproduced against
the live API, not copied from a changelog or a forum post, and stays checked: `npm run
verify-gotchas` re-probes the live account on demand and flags any claim whose real-world
behaviour has drifted from what's documented here (see
[Why this table is machine-checked](#why-this-table-is-machine-checked) - it's a manual check,
not part of CI).

## The gotchas

This table is the reason the repo exists. Every server built against this API eventually
rediscovers these the hard way — usually by staring at an error that reads like the wrong thing.
Captured live on 2026-08-21; see [Why this table is machine-checked](#why-this-table-is-machine-checked)
for how it stays honest.

| # | Claim | Verdict |
| --- | --- | --- |
| 1 | Cloudflare rejects the `Python-urllib` User-Agent with `403 error code: 1010`, which reads exactly like an API-key scope failure but is not | **HOLDS** |
| 2 | `DELETE` rejects any request carrying a body or a `Content-Type` header (`body must be null`) | **HOLDS** |
| 3 | `POST /leads/list` silently ignores `campaign_ids`; the working filter is singular `campaign` | **HOLDS** |
| 4 | `GET /campaigns/analytics?id=` is silently ignored | **REFUTED** |
| 5 | The unfiltered `GET /campaigns/analytics` omits draft campaigns entirely | **HOLDS** |
| 6 | The campaign timezone field is a restricted enum: only `America/Dawson`, `America/Chicago`, `America/Detroit` | **UNVERIFIABLE** by the read-only probe |
| 7 | Webhook `event_type` is narrower than the docs: `auto_reply_received` and `link_clicked` are documented but rejected with 400 | **UNVERIFIABLE** by the read-only probe |
| 8 | Reads are not internally consistent — `/leads/list` and `/campaigns/analytics` can contradict each other | **UNVERIFIABLE** (intermittent by nature) |

Notes on the interesting rows:

- **#3** — the live probe sent `campaign_ids: [id]` and got back 5 leads, **all 5** belonging to
  other campaigns. The parameter isn't just ignored, it's silently a no-op filter; the singular
  `campaign` parameter is what actually scopes the query. `list_leads` verifies every returned
  lead's own `campaign` field for exactly this reason and warns instead of trusting the filter.
- **#4** — this was recorded `HOLDS` on 2026-08-17 and **flipped to `REFUTED`** on 2026-08-21.
  `?id=` now correctly filters analytics to the single campaign. See below for why that flip is
  the whole point of this repo.
- **#5** — `UNVERIFIABLE` on 2026-08-17 (no draft campaign existed in the account to test against),
  then confirmed `HOLDS` by the **live integration suite** (`INSTANTLY_LIVE_TEST=1`), which creates
  a throwaway draft campaign and confirms the unfiltered `/campaigns/analytics` omits it. The
  `HOLDS` above is verified that way, **not** by `verify-gotchas`'s read-only probe: that probe
  returns `UNVERIFIABLE` whenever no draft campaign already exists in the account (it never creates
  one), so running it against a no-draft account is expected to say "could not re-check", not
  contradict this row. `list_campaigns` reads from `GET /campaigns` for this reason — that
  endpoint does include drafts.
- **#6, #7, #8** are `UNVERIFIABLE` by the read-only probe on principle, not by accident: #6 and
  #7 would require a live write (creating a campaign / webhook) that the probe script
  deliberately never performs against a real account, and #8 is an intermittent read-consistency
  issue that can't be forced on demand. `UNVERIFIABLE` is a real, honest outcome here — see below.

### Why this table is machine-checked

A hand-maintained quirk list rots. Claim #4 above is the proof: it was recorded as `HOLDS` on
2026-08-17 and **refuted four days later**, on 2026-08-21, when Instantly apparently fixed the
`?id=` parameter server-side. Four days is not a long tail — it's how fast an undocumented API
can move under a written-down assumption.

`npm run verify-gotchas` re-runs every claim's probe against the live API and prints a **five-column**
table (`#`, Claim, Verdict, Observed, Last checked) — a superset of the three-column summary above,
carrying the live probe's raw evidence and the date it ran. That is not the same shape as the table
above; don't expect a byte-for-byte match.

Each claim also carries a documented **expected** verdict (`HOLDS` for #1–#3 and #5, `REFUTED` for
#4, `UNVERIFIABLE` for #6–#8) — the currently-documented state, i.e. what this README says today.
The script exits non-zero **only when a probe's actual verdict has genuinely changed from that
expectation** (e.g. a documented `HOLDS` comes back `REFUTED`), and prints exactly which claim
drifted and in which direction. Re-confirming an already-documented `REFUTED` claim (like #4) is
not drift and does not fail the run — only a *new* change does.

`UNVERIFIABLE` is a real outcome the script reports honestly, not a failure it papers over, and it
never counts as drift in either direction. Some claims genuinely can't be checked by a safe,
read-only, non-destructive probe (see #6–#8 above); the script says so rather than guessing or
skipping silently. #5 is the clearest case: its documented `HOLDS` comes from the live integration
suite, not this probe, so the probe coming back `UNVERIFIABLE` (no draft campaign exists right now)
is reported as "could not re-check" — not a failure.

```bash
INSTANTLY_API_KEY=your-key npm run verify-gotchas
```

`verify-gotchas` is **run manually, not wired into CI** — check `.github/workflows/ci.yml`, it only
runs `build`, `typecheck`, and `test`. That's a deliberate choice, not an oversight: CI has no live
API key (the script self-skips cleanly without one, printing a message and exiting 0 — see the top
of `scripts/verify-gotchas.ts` — so it would be a silent no-op there anyway), and this script exists
to touch a real account's read endpoints, which a repo's CI has no business doing unattended. Run it
locally against your own account when you want a fresh read.

## Install

```json
{
  "mcpServers": {
    "instantly": {
      "command": "npx",
      "args": ["-y", "instantly-ai-mcp"],
      "env": { "INSTANTLY_API_KEY": "your-v2-api-key" }
    }
  }
}
```

Get a v2 API key from Instantly's dashboard under Settings → Integrations → API. Requires Node 20+.

## The safety model

Tools are grouped into three tiers, gated by environment variables. **A disabled tier is not
registered with the MCP server at all** — a model talking to this server literally cannot see or
attempt a tool it isn't allowed to use, this isn't a runtime permission check that a clever prompt
could talk its way around.

| Tier | Enabled by | Tools | Behaviour |
| --- | --- | --- | --- |
| Read | always on | 6 tools | Read-only. `readOnlyHint: true`. |
| Write | `INSTANTLY_MCP_WRITE=1` | 5 tools | Creates/updates data, but nothing irreversible. |
| Dangerous | `INSTANTLY_MCP_WRITE=1` **and** `INSTANTLY_MCP_ALLOW_DANGEROUS=1` | 4 tools | Sends real email, activates campaigns, deletes data. |

The dangerous tier requires **both** flags on purpose: turning on routine writes (uploading leads,
blocklisting an address) never silently also enables campaign activation, sending, or deletion.
Those four tools additionally carry MCP's `destructiveHint: true` annotation — a hint that a
compliant client *may* act on (for example by prompting the user for confirmation) even when the
tier is enabled. It is client-enforced behaviour, not a guarantee this server makes: a client that
ignores the hint will call the tool without any extra confirmation step.

## Tools

### Read (always registered)

- `list_campaigns` — list every campaign including drafts, with numeric status decoded.
- `list_accounts` — list connected sending mailboxes with warmup score, status, and daily limit.
- `campaign_state` — cross-check one campaign's state across three independent endpoints and report where they disagree, rather than picking a winner. The lead-list reading is page-scoped (one page, limit 100); a full page is reported plainly as page-limited, never as an Instantly disagreement.
- `list_leads` — list a campaign's leads, filtered by the singular `campaign` parameter, with a warning if any returned lead's own campaign field disagrees. Reads one page (default limit 100); `pageLimited` in the result tells you when more leads may exist beyond it.
- `find_lead` — find one lead by email via the `search` parameter; the correct second opinion when `list_leads` looks wrong. `search` is fuzzy, so the row is returned only when its own address matches the one asked for - a near-match is reported as `null`, never as the lead.
- `list_replies` — list received replies with quoted-thread/signature stripped and interest status decoded.

### Write (`INSTANTLY_MCP_WRITE=1`)

- `add_leads` — upload leads to a campaign, verified by diff (not by count) across two independent read paths. On a campaign with more than 100 leads, the verification read is page-limited too - the result's `pageLimited` and `note` fields say so.
- `blocklist_address` — blocklist one full email address; structurally refuses bare domains.
- `update_lead` — patch a lead's fields.
- `create_campaign` — create a campaign as a draft (never sends); validates the timezone enum before any network call.
- `create_webhook` — create a webhook subscription; validates the event-type enum before any network call.

### Dangerous (`INSTANTLY_MCP_WRITE=1` and `INSTANTLY_MCP_ALLOW_DANGEROUS=1`)

- `set_campaign_status` — activate or pause a campaign; activating starts sending real email immediately.
- `send_reply` — send a real, unrecallable reply to a lead. Plain text is HTML-escaped and line-broken for the `html` body rather than pasted in raw; pass `html` yourself to override.
- `delete_lead` — permanently delete a lead.
- `delete_campaign` — permanently delete a campaign and its history.

## Known limitations

`list_replies` strips the quoted original thread and signature from each reply
(`src/reply-text.ts`). It is deliberately conservative: on ambiguous input it leaves the quote in
rather than risk deleting real text. Every remaining edge below therefore fails in the SAFE
direction - a quoted thread survives into the returned text, which is noise, rather than a
sentence being deleted, which is lost data:

- An attribution naming only a weekday, e.g. `On Tuesday ... wrote:`, carries none of the
  date/time signal the stripper requires, so it is not stripped.
- An attribution naming a lowercase sender with no address, e.g. `... at 8:22 AM, john wrote:`,
  fails the sender-shape check (a real sender reads as an address, a capitalised name, or a
  pronoun) and is not stripped.
- A body that is *entirely* a signature (`-- ` on the first non-blank line, with nothing
  before it) is returned whole, delimiter included, rather than emptied.

Two over-strips found during the build did delete real prospect text: a body beginning with
`-- ` was emptied completely, and prose shaped like `On May 5 reasons you wrote: ...` was
misread as a quoted-thread marker and cut. **Both were fixed before the first release** and are
covered by the offline suite (`test/reply-text.test.ts`, "Fix round 4").

There is still no tool that returns a reply's raw, unstripped `body.text`. If a reply from
`list_replies` reads suspiciously short, check it in the Instantly dashboard before concluding the
prospect said less than they did.

## Prior art

An existing package, [`instantly-mcp`](https://www.npmjs.com/package/instantly-mcp) by
`bcharleson`, covers similar ground and was last published 2025-06-17. As of 2026-08-21, its npm
`latest` tag points at `1.0.5` while its `next` tag carries `3.0.5-1` — so a plain `npx
instantly-mcp` installs a much older build than the package's own newest published code (dist tags
can change after this was written; re-check `npm view instantly-mcp dist-tags` for the current
state). This is stated factually, not as a knock: `instantly-ai-mcp` is an independent,
unaffiliated project with a different focus (the gotchas table and its self-verification) rather
than a fork or a replacement.

## Testing

The fixture suite (`npm test`) runs entirely offline against mocked clients and needs no API key.
A separate live integration suite, gated behind `INSTANTLY_LIVE_TEST=1` (and a real
`INSTANTLY_API_KEY`), exercises the real API — but it only ever creates, reads, and deletes **its
own** throwaway draft campaign (named `zz-instantly-ai-mcp-throwaway-<timestamp>`), never an
existing campaign or lead, and never activates or sends anything. It self-skips whenever the flag
or key is absent, which is always true in CI.

## License

MIT