Ein Model-Context-Protocol-Server, der Claude scoped Zugriff auf Ihr Apollo-Konto gibt: Prospect-Suche gegen die Apollo-Datenbank (ohne Credits zu verbrauchen), Suche über die gespeicherten Contacts Ihres Teams, Auflistung von Sequences und Engagement-Stats pro Sequence — plus zwei abgesicherte Writes und eine Credit-verbrauchende Enrichment, jeweils hinter einer Justification. Stecken Sie ihn in Claude Desktop oder Claude Code, und Ihr Team kann fragen „Welche VPs of Sales bei Fintechs unter 500 Personen sind noch in keiner Sequence?” und „Reichere diese Shortlist an, Justification: Research vor dem Call für die Acme-Renewal” — ohne den Chat zu verlassen und ohne dem Modell einen Knopf zu geben, der Credits verbrennt oder eine Liste massenhaft einschreibt. Das vollständige Scaffold liegt im Artefakt-Bundle unter apps/web/public/artifacts/mcp-server-apollo-revops/ und liefert ein README.md, pyproject.toml und src/apollo_revops_mcp/server.py, bereit zur Installation mit pip install -e ..
Wann Sie das einsetzen
Greifen Sie dazu, wenn Prospecting und Sequence-Triage einen klaren wöchentlichen Rhythmus haben — eine Liste bauen, prüfen wer schon in einer Sequence ist, Reply-Rates lesen, die wenigen Datensätze anreichern, die Sie tatsächlich anrufen — und die Kosten des Hin- und Herspringens zwischen der Apollo-UI und Ihrem Arbeitsdokument pro Frage die Kosten des Lookups selbst übersteigen. Zwei Rollen holen am meisten heraus. Der RevOps-Lead, der Apollo bisher in einem Tab offen hatte, fragt jetzt Claude in natürlicher Sprache und fügt eine strukturierte Antwort in einen Pipeline-Review ein. Der GTM Engineer, der bisher für jede einmalige Liste ein Wegwerf-Skript gegen die REST-API von Apollo schrieb, hat jetzt die vier Lese-Endpoints bereits gekapselt, wobei die Credit-verbrauchenden und Send-auslösenden Calls hinter expliziten Gates eingezäunt sind.
Es ist auch das richtige Muster, wenn Sie den Salesforce-RevOps-MCP-Server bereits ausgerollt haben und dieselbe Form von Tools über Ihre Prospecting- und System-of-Record-Oberflächen wollen, damit die Claude-Prompts Ihres Teams portabel bleiben. Gleicher Antwortstil, gleiche Justification-Haltung bei allem, was schreibt oder Geld kostet.
Wann Sie das NICHT einsetzen
Lassen Sie es, wenn eines davon zutrifft:
Sie sind auf einem Plan ohne erweiterten API-Zugriff. Apollo begrenzt den erweiterten API-Zugriff auf den Organization-Plan ($119/Nutzer/Monat bei jährlicher Abrechnung, Minimum 3 Seats per Juni 2026). Die Sequence-Tools brauchen zudem einen Master-API-Key — ein Standard-Key liefert 403. Wenn Sie keinen Master-Key auf einem Seat der Organization-Stufe erstellen können, läuft die Sequence-Hälfte dieses Servers nicht; rollen Sie nur das Prospecting-Subset aus.
Compliance verbietet Prospect-PII in einem Drittanbieter-LLM. Suchergebnisse (Namen, Titel, Firmen) sind risikoarm, aber Enrichment liefert E-Mails und — wenn Sie den Webhook verdrahten — Telefonnummern zurück. Wenn Prospect-PII in einem LLM ausgeschlossen ist, halten Sie APOLLO_ALLOW_CREDIT_SPEND=false und nutzen den Server nur zum Listenbau, und enthüllen dann innerhalb von Apollo.
Ihr Team versendet aus einem Sequencer, der nicht Apollo ist. Wenn das eigentliche Outbound über Outreach, Salesloft oder Smartlead läuft, zeigen die Tools add_contacts_to_sequence und Engagement auf das falsche System. Nutzen Sie hier die Prospecting-Reads und verdrahten Sie die Send-Hälfte mit dem Tool, das sie besitzt.
Sie haben nur ein bis zwei Prospecting-Fragen pro Woche. Der amortisierte Wert liegt unter den Setup- und Token-Kosten. Bleiben Sie bei den gespeicherten Suchen in der Apollo-UI.
Was er bereitstellt
Sieben Tools, gruppiert danach, was sie Sie kosten.
Prospecting und Reads (keine Credits):search_people trifft POST /mixed_people/api_search — die API-optimierte Suche, die Match-Metadaten zurückgibt, keine Credits verbraucht und keine E-Mails enthüllt. search_contacts trifft POST /contacts/search für die Personen, die Ihr Team explizit gespeichert hat.
Sequence-Reads (Master-Key):list_sequences (POST /emailer_campaigns/search) und sequence_engagement (GET /emailer_messages/search), das Nachrichten-Zählungen nach Status — delivered, opened, clicked, replied, bounced — für eine Sequence aggregiert.
Credit-verbrauchende Enrichment (abgesichert):enrich_person (POST /people/match) verbraucht Credits und ist deaktiviert, sofern nicht APOLLO_ALLOW_CREDIT_SPEND=true, mit einer Pflicht-Justification von mindestens 10 Zeichen.
Writes (abgesichert):create_contact (POST /contacts, run_dedupe standardmäßig true) und add_contacts_to_sequence (POST /emailer_campaigns/{id}/add_contact_ids), beide erfordern eine Justification; die Einschreibung ist hart auf 25 Contacts pro Call gedeckelt.
Es gibt kein Delete-Tool, kein Bulk-Enrichment, keine unbegrenzte Einschreibung. Das Prinzip, gleich wie beim Salesforce-Server: Jede abrechenbare oder irreversible Aktion bekommt ihren eigenen benannten Knopf, niemals einen Freitext-Befehl.
Engineering-Haltung
Ein paar eigensinnige Entscheidungen, die man vor der Übernahme des Scaffolds verstehen sollte.
Suche ist gratis; Reveal ist die Kostenposition.search_people nutzt bewusst den Credit-freien Endpoint mixed_people/api_search, der keine Kontaktdetails zurückgibt. Eine E-Mail oder Telefonnummer zu bekommen ist ein separater enrich_person-Call. Die Trennung bedeutet, dass Claude eine 200-Personen-Liste zu null Credit-Kosten bauen und verfeinern kann und Sie nur ausgeben, wenn Sie die Handvoll anreichern, auf die Sie reagieren werden. Die beiden zusammenzulegen — bei jeder Suche zu enthüllen — ist, wie Teams ein Credit-Guthaben an einem Vormittag verbrennen.
Credit-Ausgabe ist ein Kill-Switch, kein Prompt.enrich_person prüft APOLLO_ALLOW_CREDIT_SPEND, bevor es läuft. Standardmäßig aus. Die Alternative — allein dem Justification-Text zu vertrauen — lässt die Ausgabe ein selbstbewusstes Missverständnis entfernt. Das Flag macht „Erlauben wir Chat-getriebenes Enrichment überhaupt?” zu einer expliziten Deployment-Entscheidung.
Die Einschreibung ist by-construction gedeckelt.add_contacts_to_sequence verweigert mehr als APOLLO_MAX_SEQUENCE_BATCH (Default 25) Contacts in einem einzigen Call und verlangt ein benanntes Versand-Postfach. Der Apollo-Endpoint würde gern Hunderte einschreiben; der Deckel hält eine versehentliche Einschreibung klein genug, um sie von Hand rückgängig zu machen.
Dedup ist eingeschaltet. Apollo dedupliziert neue Contacts standardmäßig nicht — ein Re-Run mit demselben Payload erzeugt einen zweiten Datensatz. create_contact setzt run_dedupe=true, sodass ein wiederholter Call aktualisiert, statt Duplikate auszufächern.
Kostenrealität
Drei Kostenpositionen.
Claude-Abo. Was Sie ohnehin für Claude Desktop oder Claude Code zahlen (Pro mit $20/Nutzer/Monat, Max-Stufen $100-200/Nutzer/Monat, oder API-Verbrauch). Der Server ändert das nicht.
Self-Hosting des Servers. Ein lokaler Python-Prozess pro Claude-Desktop-Nutzer — null Infra-Kosten auf einem Laptop. Kapseln Sie ihn als geteilten Service und budgetieren Sie eine kleine VM, $20-50/Monat in jeder Cloud.
Apollo-Credits und API-Quota. Suche verbraucht keine Credits. Enrichment schon — Apollo berechnet rund 1 Credit pro verifizierter Business-E-Mail und etwa 8 Credits pro enthüllter Mobilnummer (Juni 2026). Ein RevOps-Lead, der 20-40 Datensätze pro Woche anreichert, bleibt bequem innerhalb eines Standard-Credit-Kontingents; die Gefahr ist Bulk-Enrichment, weshalb es genau ein Gate hat. Apollo erzwingt zudem API-Rate-Limits pro Minute, pro Stunde und pro Tag, die mit dem Plan skalieren (Free sind 600 Requests/Tag; bezahlte Stufen liegen höher) — lesen Sie Ihre exakten Limits über den View-API-Usage-Stats-Endpoint.
Die Token-Kosten auf Claudes Seite werden von den Antwort-Payloads dominiert, daher dünnt das Scaffold Suchergebnisse vor der Rückgabe auf id, Name, Titel, Firma und Link aus. Eine Suche über 25 Datensätze landet deutlich unter 10K Tokens; ein paar Suchen pro Prospecting-Session pro Woche sind einstellige Dollar/Nutzer/Monat zusätzlich zum Abo.
Wie Erfolg aussieht
Ein messbares Signal nach einem Monat: Die Time-to-Answer bei „Wen sollte ich diese Woche bearbeiten?” sinkt von „Apollo öffnen, die gespeicherte Suche neu bauen, mit Sequences abgleichen, exportieren” (sagen wir fünf Minuten oder mehr) auf „Claude fragen, die Antwort lesen” (unter einer Minute). Das schwerer messbare, aber tragendere Signal: Das Team hört auf, ganze Listen „sicherheitshalber” anzureichern, weil das Anreichern der wenigen Datensätze, die es tatsächlich anfasst, jetzt der Weg des geringsten Widerstands ist — der Credit-Verbrauch pro gebuchtem Meeting sinkt, statt zu steigen.
Gegenüber den Alternativen
Apollos eigene AI und gespeicherte Suchen. First-Party, kein Prozess zu hosten, und die Daten sind schon da. Trade-off: Sie leben in der Apollo-UI, und deren Assistent kann Apollo-Daten nicht mit dem Rest Ihres Claude-Kontexts verbinden. Wählen Sie die native UI, wenn Ihr Team in Apollo lebt; wählen Sie diesen Server, wenn es in Claude lebt und eine einzige Chat-Oberfläche über Prospecting und den Salesforce-Server will.
Ein Wegwerf-Skript gegen die REST-API von Apollo. Maximale Kontrolle, maximaler Wartungsaufwand und null Guardrails — jedes Team baut Auth, Paging, das Credit-Gate und den Einschreibungs-Deckel von Hand neu. Dieses Scaffold gibt Ihnen all das in rund 400 Zeilen, und der Credit-Kill-Switch ist bereits verdrahtet.
Eine No-Code-Plattform (Clay, n8n). Exzellent für geplante, produktivierte Enrichment-und-Routing-Pipelines. Andere Form als „eine Ad-hoc-Frage stellen, für die ich keinen Flow vorgebaut habe”. Sie sind Ergänzungen: Clay oder n8n für den wiederkehrenden Waterfall, dieser Server für das Gespräch. Wenn Sie die Architektur hinter der wiederkehrenden Version wollen, lesen Sie den AI-SDR-Primer.
Watch-outs
Das README dokumentiert diese vollständig; die Kurzfassung:
Credit-Drain beim Enrichment. Ein achtloses „reichere all diese an” gegen eine 500-Zeilen-Liste kann in Minuten Tausende Credits ausgeben. Guard: enrich_person ist aus, sofern nicht APOLLO_ALLOW_CREDIT_SPEND=true, verarbeitet einen Datensatz pro Call und erzwingt reveal_phone_number=false (das teure Feld) in diesem Scaffold.
Versehentliche Masseneinschreibung.add_contacts_to_sequence löst echte Sends aus. Guard: Der Call verweigert mehr als 25 Contacts (APOLLO_MAX_SEQUENCE_BATCH), verlangt ein benanntes Versand-Postfach und fordert eine Justification — es gibt keinen „alle einschreiben”-Pfad.
Master-Key-403 an einem Freitag. Die Sequence-Tools scheitern still, wenn der Key kein Master-Key ist. Guard: Das Scaffold fängt den 403 ab und liefert eine klare Meldung, die die Master-Key-Anforderung und den Erstellungsort benennt, statt eines rohen Stack-Trace.
Duplikat-Contact-Ausfächerung. Apollo dedupliziert standardmäßig nicht, also macht ein wiederholtes create_contact einen zweiten Datensatz. Guard: run_dedupe ist standardmäßig true.
Rate-Limit-Überraschungen. Eine Bulk-Paging-Schleife kann an Apollos Minuten- oder Tagesgrenze stoßen. Guard: Jede Suche ist auf 100/Seite gedeckelt und das Scaffold legt den 429 explizit offen; fügen Sie Backoff (TODO #1 im README) vor jedem unbeaufsichtigten Einsatz hinzu.
Stack
Apollo — Prospecting-Datenbank, Contacts, Sequences, Enrichment
MCP Python SDK — das Paket mcp>=1.2.0; stellt Server, stdio_server und die Tool-Registry-Dekoratoren bereit
httpx — async REST-Client gegen api.apollo.io, authentifiziert mit dem x-api-key-Header
Claude Desktop oder Claude Code — Natural-Language-Schnittstelle, Tool-Caller
APOLLO_ALLOW_CREDIT_SPEND — der Kill-Switch auf Env-Ebene, der entscheidet, ob Chat-getriebenes Enrichment überhaupt erlaubt ist
# mcp-server-apollo-revops
An MCP server tuned for revenue-operations teams using Apollo.io. Exposes prospect search against the Apollo people database (no credits), search over your team's saved Contacts, sequence listing, and per-sequence engagement stats — plus two gated writes (`create_contact`, `add_contacts_to_sequence`) and one credit-spending enrichment (`enrich_person`). Built so Claude can answer "which VPs of Sales at fintechs under 500 employees aren't in any sequence yet?" without handing the model a button that drains your credit balance or mass-enrolls a list.
> **STATUS: scaffold — not runtime-tested.** The code follows the official `mcp` Python SDK conventions and the endpoint paths/parameters track the public Apollo API docs (docs.apollo.io) as of June 2026, but it has not been executed against a live Apollo account. Endpoint behavior, field names, and plan-gated access vary; verify against your account before relying on it.
## What it exposes
### Prospecting and reads (no credits)
- `search_people(person_titles?, person_seniorities?, person_locations?, organization_domains?, q_keywords?, page=1, per_page=25)` — searches the Apollo database via `POST /mixed_people/api_search`. This endpoint is built for API use and **does not consume credits** and **does not reveal emails or phone numbers** — it returns match metadata for building a list. Reveal is a separate, credit-spending step (`enrich_person`).
- `search_contacts(q_keywords?, contact_stage_ids?, contact_label_ids?, sort_by_field?, page=1, per_page=25)` — searches your team's saved Contacts via `POST /contacts/search`. A *contact* is a person your team has explicitly added; this is distinct from raw database people.
### Sequence reads (require a master API key)
- `list_sequences(q_name?, page=1, per_page=25)` — `POST /emailer_campaigns/search`. Returns sequences in your team's account.
- `sequence_engagement(emailer_campaign_id, date_min?, date_max?, per_page=100)` — `GET /emailer_messages/search` filtered to one sequence, aggregating message counts by status (delivered, opened, clicked, replied, bounced). The summary covers the sampled page(s); page through for full totals.
### Credit-spending enrichment (gated)
- `enrich_person(..., justification)` — `POST /people/match`. **Consumes Apollo credits.** Disabled unless `APOLLO_ALLOW_CREDIT_SPEND=true`, and requires a `justification` of at least 10 characters. `reveal_phone_number` is forced to `False` in this scaffold because Apollo requires a configured `webhook_url` to return phone numbers asynchronously.
### Writes (gated)
- `create_contact(first_name, last_name, organization_name?, title?, email?, run_dedupe=true, justification)` — `POST /contacts`. Requires a `justification`. `run_dedupe` defaults to `true`; Apollo does **not** dedupe by default, so a re-run with the flag off fans out duplicates.
- `add_contacts_to_sequence(sequence_id, contact_ids, send_from_email_account_id?, justification)` — `POST /emailer_campaigns/{sequence_id}/add_contact_ids`. Requires a master key, a `justification`, and a send-from mailbox. Hard-capped at `APOLLO_MAX_SEQUENCE_BATCH` (default 25) contacts per call so an accidental enrollment stays small enough to undo by hand.
The server **does not** expose delete tools, bulk enrichment, or unbounded sequence enrollment. Every search caps at 100 records per page (Apollo's own ceiling is 100/page × 500 pages = 50,000 rows).
## Setup
### 1. Install
```bash
git clone <wherever you put this>
cd mcp-server-apollo-revops
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e .
```
### 2. Generate an Apollo API key
In Apollo: **Settings → Integrations → API**. Create a key.
- **Master vs. non-master.** The sequence and outreach-email endpoints (`list_sequences`, `sequence_engagement`, `add_contacts_to_sequence`) require a **master** API key — a non-master key returns `403` on those. If you want the full tool set, make the key a master key. If you only need prospecting (`search_people`, `search_contacts`, `enrich_person`), a non-master key is enough.
- **Plan gating.** All Apollo plans get basic API access, but advanced API access is gated to the Organization plan ($119/user/month on annual billing, 3-seat minimum as of 2026-06). Confirm your plan exposes the endpoints you need before wiring this up — see [API Pricing](https://docs.apollo.io/docs/api-pricing).
### 3. Configure environment
```bash
export APOLLO_API_KEY="your-master-or-standard-key"
export APOLLO_BASE_URL="https://api.apollo.io/api/v1" # optional, this is the default
export APOLLO_ALLOW_CREDIT_SPEND="false" # set true to enable enrich_person
export APOLLO_MAX_SEQUENCE_BATCH="25" # cap on add_contacts_to_sequence
export APOLLO_DEFAULT_SEND_ACCOUNT_ID="" # optional default sending mailbox
```
Env var notes:
- **`APOLLO_API_KEY`** — found in Settings → Integrations → API. Sent as the `x-api-key` header (Apollo does *not* use Bearer auth). Use a master key for the sequence tools.
- **`APOLLO_BASE_URL`** — only change this if Apollo moves the API host or you front it with a proxy.
- **`APOLLO_ALLOW_CREDIT_SPEND`** — the credit kill-switch. While `false`, `enrich_person` refuses to run. Flip to `true` only once you've decided enrichment-via-chat is allowed and have a credit budget.
- **`APOLLO_MAX_SEQUENCE_BATCH`** — the blast-radius cap on `add_contacts_to_sequence`. Keep it small (25 is a sane default); raise it deliberately, never to "just get the import done."
- **`APOLLO_DEFAULT_SEND_ACCOUNT_ID`** — the Apollo ID of the mailbox sequences send from. Find it via the linked-accounts settings. Lets callers omit `send_from_email_account_id` per call.
### 4. Register with Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"apollo-revops": {
"command": "python",
"args": ["-m", "apollo_revops_mcp.server"],
"env": {
"APOLLO_API_KEY": "your-master-or-standard-key",
"APOLLO_BASE_URL": "https://api.apollo.io/api/v1",
"APOLLO_ALLOW_CREDIT_SPEND": "false",
"APOLLO_MAX_SEQUENCE_BATCH": "25",
"APOLLO_DEFAULT_SEND_ACCOUNT_ID": ""
}
}
}
}
```
Restart Claude Desktop. You should see 7 tools registered under `apollo-revops`.
### 5. Sanity-check
Ask Claude: "Search Apollo for VPs of Sales at companies using HubSpot, limit 10." Confirm you get names and titles back with no credit consumption (check the credit balance before and after — it should not move). Then ask "List my sequences" to confirm the master-key path works. Only after both succeed should you flip `APOLLO_ALLOW_CREDIT_SPEND=true` and test `enrich_person` on a single record.
## Security model
- **Token scope.** The `x-api-key` can do whatever your plan permits — including credit-spending enrichment and triggering sends. Treat it as a production secret. Prefer a dedicated API user / key over a personal one so you can rotate it without breaking a human's login.
- **Who sees what data.** Search results (names, titles, companies) flow into Claude's context. Enrichment results (emails, and — if you wire the webhook — phone numbers) are PII. If your compliance regime forbids pushing prospect PII into a third-party LLM, keep `APOLLO_ALLOW_CREDIT_SPEND=false` and use the server for list-building only, then reveal inside Apollo.
- **Spend and send are gated, not free-text.** Enrichment is behind an env flag; both writes require a 10-character justification; sequence enrollment is capped per call. There is no delete tool. Every irreversible or billable action has its own named tool, never a generic "do X" command.
## Limits and TODOs (before production use)
- [ ] Add request-level retries with exponential backoff and jitter on `429` (Apollo enforces per-minute, per-hour, and per-day limits that vary by plan; read them via the View API Usage Stats endpoint).
- [ ] Implement the `reveal_phone_number` path with a real `webhook_url` receiver (Apollo returns phone numbers asynchronously to a webhook, not inline).
- [ ] Page through `sequence_engagement` to produce true totals instead of a sampled-page summary.
- [ ] Write integration tests against a sandbox/throwaway Apollo seat (mock `httpx` with `pytest-httpx`).
- [ ] Add structured logging: one JSON line per tool call with name, arguments hash, duration, status, and (for `enrich_person`) credits estimated to be spent.
- [ ] Validate `APOLLO_DEFAULT_SEND_ACCOUNT_ID` against the account's linked mailboxes on first run; fail loud if it doesn't exist.
- [ ] Add a `--dry-run` flag that returns the request body/params without executing, for the two writes and enrichment.
- [ ] Surface remaining credit balance as a tool so Claude can warn before a large enrichment batch.
"""
apollo-revops-mcp — MCP server tuned for revenue-operations prospecting on Apollo.io.
Exposes prospect search against the Apollo database (no credits), search over your
team's saved Contacts, sequence listing, and per-sequence engagement stats — plus
two gated actions: person enrichment (spends Apollo credits) and two writes
(create_contact, add_contacts_to_sequence). Read-mostly by design. The two writes
and the credit-spending enrichment each require a justification string; enrichment
additionally requires the APOLLO_ALLOW_CREDIT_SPEND env flag, so Claude cannot burn
credits or mass-enroll contacts by misreading a question.
STATUS: scaffold — not runtime-tested. Endpoint paths and parameters track the
public Apollo API docs (docs.apollo.io) as of 2026-06; verify against your account's
plan limits and field names before use.
Run as: python -m apollo_revops_mcp.server
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
# ----- Configuration (read from env at startup) -----
APOLLO_API_KEY = os.environ.get("APOLLO_API_KEY")
APOLLO_BASE_URL = os.environ.get("APOLLO_BASE_URL", "https://api.apollo.io/api/v1").rstrip("/")
# Enrichment (POST /people/match) spends Apollo credits. Off unless explicitly
# opted in, so a misread question cannot quietly drain the team's credit balance.
APOLLO_ALLOW_CREDIT_SPEND = os.environ.get("APOLLO_ALLOW_CREDIT_SPEND", "false").lower() == "true"
# Hard cap on contacts added to a sequence per call. The add-to-sequence endpoint
# happily enrolls hundreds at once; this cap keeps an accidental enrollment small
# enough to undo by hand.
APOLLO_MAX_SEQUENCE_BATCH = int(os.environ.get("APOLLO_MAX_SEQUENCE_BATCH", "25"))
# Optional default sending mailbox for add_contacts_to_sequence. Apollo requires a
# send-from account id; set this so callers do not have to pass it every time.
APOLLO_DEFAULT_SEND_ACCOUNT_ID = os.environ.get("APOLLO_DEFAULT_SEND_ACCOUNT_ID")
# Page-size ceiling. Apollo caps every search at 100/page, 500 pages (50,000 rows).
# We default smaller and never exceed 100 to keep response payloads tractable for
# the model.
MAX_PER_PAGE = 100
DEFAULT_PER_PAGE = 25
def require_config() -> None:
if not APOLLO_API_KEY:
raise RuntimeError("APOLLO_API_KEY env var is required")
def auth_headers() -> dict[str, str]:
# Apollo authenticates with an x-api-key header, NOT a Bearer token. The
# sequence and outreach-email endpoints additionally require a *master* key;
# a non-master key returns 403 on those.
return {
"x-api-key": APOLLO_API_KEY or "",
"Content-Type": "application/json",
"Cache-Control": "no-cache",
}
def clamp_per_page(value: Any) -> int:
try:
n = int(value)
except (TypeError, ValueError):
return DEFAULT_PER_PAGE
return max(1, min(n, MAX_PER_PAGE))
# ----- Apollo REST helpers -----
async def apollo_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(f"{APOLLO_BASE_URL}{path}", headers=auth_headers(), json=body)
_raise_for_apollo(r)
return r.json() if r.content else {}
async def apollo_get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{APOLLO_BASE_URL}{path}", headers=auth_headers(), params=params)
_raise_for_apollo(r)
return r.json() if r.content else {}
def _raise_for_apollo(r: httpx.Response) -> None:
if r.status_code == 403:
raise PermissionError(
"Apollo returned 403. The sequence and outreach-email endpoints require a "
"MASTER API key; generate one in Settings -> Integrations -> API and set it "
"as APOLLO_API_KEY."
)
if r.status_code == 429:
raise RuntimeError(
"Apollo returned 429 (rate limit). Check per-minute/hour/day limits with the "
"View API Usage Stats endpoint; back off and retry."
)
r.raise_for_status()
# ----- Server + tool registry -----
server = Server("apollo-revops")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="search_people",
description=(
"Prospect the Apollo people database (POST /mixed_people/api_search). "
"Does NOT consume credits and does NOT reveal emails/phones — it returns "
"match metadata for building a list. Filter by title, seniority, location, "
"and company domain. Max 100 per page."
),
inputSchema={
"type": "object",
"properties": {
"person_titles": {"type": "array", "items": {"type": "string"}},
"person_seniorities": {"type": "array", "items": {"type": "string"}},
"person_locations": {"type": "array", "items": {"type": "string"}},
"organization_domains": {
"type": "array",
"items": {"type": "string"},
"description": "Company domains, e.g. ['stripe.com'].",
},
"q_keywords": {"type": "string"},
"page": {"type": "integer", "default": 1},
"per_page": {"type": "integer", "default": DEFAULT_PER_PAGE},
},
},
),
Tool(
name="search_contacts",
description=(
"Search your team's saved Apollo Contacts (POST /contacts/search). A "
"contact is a person your team has explicitly added — distinct from raw "
"database people. Filter by keyword, stage, and label."
),
inputSchema={
"type": "object",
"properties": {
"q_keywords": {"type": "string"},
"contact_stage_ids": {"type": "array", "items": {"type": "string"}},
"contact_label_ids": {"type": "array", "items": {"type": "string"}},
"sort_by_field": {"type": "string"},
"page": {"type": "integer", "default": 1},
"per_page": {"type": "integer", "default": DEFAULT_PER_PAGE},
},
},
),
Tool(
name="list_sequences",
description=(
"List sequences in your team's account (POST /emailer_campaigns/search). "
"Requires a MASTER API key. Optional q_name keyword filter."
),
inputSchema={
"type": "object",
"properties": {
"q_name": {"type": "string"},
"page": {"type": "integer", "default": 1},
"per_page": {"type": "integer", "default": DEFAULT_PER_PAGE},
},
},
),
Tool(
name="sequence_engagement",
description=(
"Summarize outreach-email engagement for one sequence "
"(GET /emailer_messages/search), aggregating message counts by status "
"(delivered, opened, clicked, replied, bounced). Requires a MASTER API key."
),
inputSchema={
"type": "object",
"properties": {
"emailer_campaign_id": {"type": "string"},
"date_min": {"type": "string", "description": "ISO date, e.g. 2026-06-01"},
"date_max": {"type": "string", "description": "ISO date, e.g. 2026-06-30"},
"per_page": {"type": "integer", "default": MAX_PER_PAGE},
},
"required": ["emailer_campaign_id"],
},
),
Tool(
name="enrich_person",
description=(
"Enrich one person via waterfall (POST /people/match). CONSUMES CREDITS. "
"Disabled unless APOLLO_ALLOW_CREDIT_SPEND=true. Requires a justification "
"(>= 10 chars). reveal_phone_number is refused here because it needs a "
"configured webhook_url."
),
inputSchema={
"type": "object",
"properties": {
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"name": {"type": "string"},
"email": {"type": "string"},
"domain": {"type": "string"},
"linkedin_url": {"type": "string"},
"reveal_personal_emails": {"type": "boolean", "default": False},
"justification": {"type": "string", "minLength": 10},
},
"required": ["justification"],
},
),
Tool(
name="create_contact",
description=(
"Create a Contact in your team's account (POST /contacts). A write. "
"Requires a justification (>= 10 chars). run_dedupe defaults true so a "
"re-run does not fan out duplicates."
),
inputSchema={
"type": "object",
"properties": {
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"organization_name": {"type": "string"},
"title": {"type": "string"},
"email": {"type": "string"},
"run_dedupe": {"type": "boolean", "default": True},
"justification": {"type": "string", "minLength": 10},
},
"required": ["first_name", "last_name", "justification"],
},
),
Tool(
name="add_contacts_to_sequence",
description=(
"Add existing contacts to a sequence "
"(POST /emailer_campaigns/{id}/add_contact_ids). A write that triggers "
"sends. Requires a MASTER API key, a justification (>= 10 chars), and a "
"send-from mailbox. Hard-capped at APOLLO_MAX_SEQUENCE_BATCH contacts/call."
),
inputSchema={
"type": "object",
"properties": {
"sequence_id": {"type": "string"},
"contact_ids": {"type": "array", "items": {"type": "string"}},
"send_from_email_account_id": {"type": "string"},
"justification": {"type": "string", "minLength": 10},
},
"required": ["sequence_id", "contact_ids", "justification"],
},
),
]
# ----- Tool dispatch -----
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
require_config()
if name == "search_people":
body: dict[str, Any] = {
"page": int(arguments.get("page", 1)),
"per_page": clamp_per_page(arguments.get("per_page", DEFAULT_PER_PAGE)),
}
if v := arguments.get("person_titles"):
body["person_titles"] = v
if v := arguments.get("person_seniorities"):
body["person_seniorities"] = v
if v := arguments.get("person_locations"):
body["person_locations"] = v
if v := arguments.get("organization_domains"):
body["q_organization_domains_list"] = v
if v := arguments.get("q_keywords"):
body["q_keywords"] = v
data = await apollo_post("/mixed_people/api_search", body)
return [TextContent(type="text", text=str(_slim_people(data)))]
if name == "search_contacts":
body = {
"page": int(arguments.get("page", 1)),
"per_page": clamp_per_page(arguments.get("per_page", DEFAULT_PER_PAGE)),
}
for key in ("q_keywords", "sort_by_field"):
if v := arguments.get(key):
body[key] = v
for key in ("contact_stage_ids", "contact_label_ids"):
if v := arguments.get(key):
body[key] = v
data = await apollo_post("/contacts/search", body)
return [TextContent(type="text", text=str(_slim_contacts(data)))]
if name == "list_sequences":
body = {
"page": int(arguments.get("page", 1)),
"per_page": clamp_per_page(arguments.get("per_page", DEFAULT_PER_PAGE)),
}
if v := arguments.get("q_name"):
body["q_name"] = v
data = await apollo_post("/emailer_campaigns/search", body)
return [TextContent(type="text", text=str(data))]
if name == "sequence_engagement":
params: dict[str, Any] = {
"emailer_campaign_ids[]": arguments["emailer_campaign_id"],
"per_page": clamp_per_page(arguments.get("per_page", MAX_PER_PAGE)),
"page": 1,
}
if v := arguments.get("date_min"):
params["emailer_message_date_range[min]"] = v
if v := arguments.get("date_max"):
params["emailer_message_date_range[max]"] = v
data = await apollo_get("/emailer_messages/search", params)
return [TextContent(type="text", text=str(_summarize_engagement(data)))]
if name == "enrich_person":
justification = (arguments.get("justification") or "").strip()
if len(justification) < 10:
raise ValueError("justification is mandatory and must be at least 10 characters.")
if not APOLLO_ALLOW_CREDIT_SPEND:
raise PermissionError(
"enrich_person consumes Apollo credits and is disabled. Set "
"APOLLO_ALLOW_CREDIT_SPEND=true to allow credit-spending enrichment."
)
body = {k: arguments[k] for k in ("first_name", "last_name", "name", "email", "domain") if arguments.get(k)}
if v := arguments.get("linkedin_url"):
body["linkedin_url"] = v
body["reveal_personal_emails"] = bool(arguments.get("reveal_personal_emails", False))
# reveal_phone_number requires a webhook_url; not wired in this scaffold.
body["reveal_phone_number"] = False
data = await apollo_post("/people/match", body)
return [TextContent(type="text", text=str(data))]
if name == "create_contact":
justification = (arguments.get("justification") or "").strip()
if len(justification) < 10:
raise ValueError("justification is mandatory and must be at least 10 characters.")
body = {
"first_name": arguments["first_name"],
"last_name": arguments["last_name"],
"run_dedupe": bool(arguments.get("run_dedupe", True)),
}
for key in ("organization_name", "title", "email"):
if v := arguments.get(key):
body[key] = v
data = await apollo_post("/contacts", body)
contact = data.get("contact", {})
return [
TextContent(
type="text",
text=f"Created contact {contact.get('id', '?')} ({justification!r}).",
)
]
if name == "add_contacts_to_sequence":
justification = (arguments.get("justification") or "").strip()
if len(justification) < 10:
raise ValueError("justification is mandatory and must be at least 10 characters.")
contact_ids = arguments.get("contact_ids") or []
if not contact_ids:
raise ValueError("contact_ids must be a non-empty list.")
if len(contact_ids) > APOLLO_MAX_SEQUENCE_BATCH:
raise ValueError(
f"Refusing to add {len(contact_ids)} contacts in one call; the cap is "
f"{APOLLO_MAX_SEQUENCE_BATCH}. Split the batch or raise "
"APOLLO_MAX_SEQUENCE_BATCH deliberately."
)
send_account = arguments.get("send_from_email_account_id") or APOLLO_DEFAULT_SEND_ACCOUNT_ID
if not send_account:
raise ValueError(
"send_from_email_account_id is required (or set APOLLO_DEFAULT_SEND_ACCOUNT_ID)."
)
seq_id = arguments["sequence_id"]
body = {
"emailer_campaign_id": seq_id,
"contact_ids": contact_ids,
"send_email_from_email_account_id": send_account,
}
data = await apollo_post(f"/emailer_campaigns/{seq_id}/add_contact_ids", body)
return [
TextContent(
type="text",
text=(
f"Added {len(contact_ids)} contact(s) to sequence {seq_id} "
f"from mailbox {send_account} ({justification!r}). Response: {data}"
),
)
]
raise ValueError(f"Unknown tool: {name}")
# ----- Response slimming (keep model payloads tractable) -----
def _slim_people(data: dict[str, Any]) -> dict[str, Any]:
people = data.get("people", []) or data.get("contacts", [])
rows = [
{
"id": p.get("id"),
"name": p.get("name"),
"title": p.get("title"),
"organization": (p.get("organization") or {}).get("name"),
"linkedin_url": p.get("linkedin_url"),
}
for p in people
]
return {"pagination": data.get("pagination"), "people": rows}
def _slim_contacts(data: dict[str, Any]) -> dict[str, Any]:
contacts = data.get("contacts", [])
rows = [
{
"id": c.get("id"),
"name": c.get("name"),
"title": c.get("title"),
"email": c.get("email"),
"stage": c.get("contact_stage_id"),
}
for c in contacts
]
return {"pagination": data.get("pagination"), "contacts": rows}
def _summarize_engagement(data: dict[str, Any]) -> dict[str, Any]:
messages = data.get("emailer_messages", [])
counts: dict[str, int] = {}
for m in messages:
status = m.get("status") or m.get("email_status") or "unknown"
counts[status] = counts.get(status, 0) + 1
total = len(messages)
return {
"sampled_messages": total,
"by_status": counts,
"note": "Counts cover the sampled page(s) only; page through for full totals.",
}
# ----- Entrypoint -----
async def main() -> None:
require_config()
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())