Um servidor Model Context Protocol que dá ao Claude acesso scopeado à sua conta do Apollo: busca de prospects contra a base de dados do Apollo (sem gastar créditos), busca sobre os Contatos salvos do seu time, listagem de sequences, e stats de engagement por sequence — além de duas escritas com gate e um enrichment que gasta créditos, cada um atrás de uma justificativa. Coloque no Claude Desktop ou Claude Code e seu time pode perguntar “quais VPs de Sales em fintechs com menos de 500 pessoas ainda não estão em nenhuma sequence?” e “enriqueça esta shortlist, justificativa: research antes da call para a renovação da Acme” sem sair do chat — e sem entregar ao modelo um botão que queima créditos ou inscreve uma lista em massa. O scaffold completo vive no bundle de artefato em apps/web/public/artifacts/mcp-server-apollo-revops/, que entrega um README.md, pyproject.toml, e src/apollo_revops_mcp/server.py prontos para instalar com pip install -e ..
Quando usar isto
Use isto quando o prospecting e a triagem de sequences têm um ritmo semanal claro — montar uma lista, checar quem já está em uma sequence, ler reply rates, enriquecer os poucos registros que você realmente vai ligar — e o custo de ficar pulando entre a UI do Apollo e seu documento de trabalho a cada pergunta domina o custo do próprio lookup. Dois papéis tiram o máximo disso. O líder de RevOps que costumava manter o Apollo aberto numa aba agora pergunta ao Claude em linguagem natural e cola uma resposta estruturada em um pipeline review. O GTM engineer que costumava escrever um script descartável contra a REST API do Apollo para cada lista pontual agora tem os quatro endpoints de leitura já encapsulados, com as chamadas que gastam créditos e disparam envios cercadas atrás de gates explícitos.
Também é o padrão certo se você já subiu o servidor MCP do Salesforce RevOps e quer a mesma forma de tools entre suas superfícies de prospecting e system-of-record, para que os prompts de Claude do seu time continuem portáveis. Mesmo estilo de resposta, mesma postura de justificativa em qualquer coisa que escreva ou gaste.
Quando NÃO usar isto
Pule se qualquer uma destas for verdade:
Você está num plano sem acesso avançado à API. O Apollo limita o acesso avançado à API ao plano Organization ($119/usuário/mês no faturamento anual, mínimo de 3 seats em junho de 2026). As tools de sequences também precisam de uma master API key — uma key padrão retorna 403. Se você não consegue criar uma master key num seat de nível Organization, a metade de sequences deste servidor não roda; suba só o subconjunto de prospecting.
O compliance proíbe PII de prospects em um LLM de terceiros. Os resultados de busca (nomes, títulos, empresas) são de baixo risco, mas o enrichment retorna emails e — se você ligar o webhook — números de telefone. Se colocar PII de prospects em um LLM está fora de cogitação, mantenha APOLLO_ALLOW_CREDIT_SPEND=false e use o servidor só para montar listas, e depois revele dentro do Apollo.
Seu time envia de um sequencer que não é o Apollo. Se o outbound real roda por Outreach, Salesloft ou Smartlead, as tools add_contacts_to_sequence e de engagement apontam para o sistema errado. Use as leituras de prospecting aqui e ligue a metade de envio à ferramenta que a possui.
Você só tem uma ou duas perguntas de prospecting por semana. O valor amortizado fica abaixo do custo de setup e de tokens. Fique com as buscas salvas na UI do Apollo.
O que ele expõe
Sete tools, agrupadas pelo que custam a você.
Prospecting e leituras (sem créditos):search_people bate em POST /mixed_people/api_search — a busca otimizada para API que retorna metadata de match, não gasta créditos e não revela emails. search_contacts bate em POST /contacts/search para as pessoas que seu time salvou explicitamente.
Leituras de sequences (master key):list_sequences (POST /emailer_campaigns/search) e sequence_engagement (GET /emailer_messages/search), que agrega contagens de mensagens por status — delivered, opened, clicked, replied, bounced — para uma sequence.
Enrichment que gasta créditos (com gate):enrich_person (POST /people/match) consome créditos e está desabilitado a menos que APOLLO_ALLOW_CREDIT_SPEND=true, com uma justificativa obrigatória de pelo menos 10 caracteres.
Escritas (com gate):create_contact (POST /contacts, com run_dedupe por padrão em true) e add_contacts_to_sequence (POST /emailer_campaigns/{id}/add_contact_ids), ambas exigindo uma justificativa; a inscrição tem um teto duro de 25 contatos por chamada.
Não há tool de delete, nem enrichment em bulk, nem inscrição sem limite. O princípio, igual ao do servidor do Salesforce: cada ação faturável ou irreversível ganha seu próprio botão nomeado, nunca um comando de texto livre.
Postura de engenharia
Algumas escolhas opinativas que vale entender antes de adotar o scaffold.
A busca é grátis; o reveal é a linha de custo.search_people usa deliberadamente o endpoint sem créditos mixed_people/api_search, que não retorna detalhes de contato. Conseguir um email ou telefone é uma chamada enrich_person à parte. A separação significa que o Claude pode montar e refinar uma lista de 200 pessoas a custo zero de créditos, e você só gasta quando enriquece o punhado sobre o qual vai agir. Colapsar as duas — revelar em cada busca — é como times incineram um balance de créditos numa manhã.
O gasto de créditos é um kill-switch, não um prompt.enrich_person checa APOLLO_ALLOW_CREDIT_SPEND antes de rodar. Desligado por padrão. A alternativa — confiar só no texto da justificativa — deixa o gasto a um mal-entendido confiante de distância. O flag transforma “estamos permitindo enrichment dirigido por chat?” numa decisão de deploy explícita.
A inscrição é limitada por construção.add_contacts_to_sequence recusa mais de APOLLO_MAX_SEQUENCE_BATCH (default 25) contatos numa única chamada e exige uma caixa de envio nomeada. O endpoint do Apollo inscreveria centenas de bom grado; o teto mantém uma inscrição acidental pequena o suficiente para desfazer na mão.
O dedup vem ligado. O Apollo não deduplica contatos novos por padrão — um re-run com o mesmo payload cria um segundo registro. create_contact seta run_dedupe=true para que uma chamada repetida atualize em vez de gerar duplicatas.
A realidade de custos
Três linhas de custo.
Assinatura do Claude. O que você já paga pelo Claude Desktop ou Claude Code (Pro a $20/usuário/mês, tiers Max $100-200/usuário/mês, ou consumo de API). O servidor não muda isso.
Self-host do servidor. Um processo Python local por usuário do Claude Desktop — custo de infra zero num laptop. Encapsule como serviço compartilhado e orce uma VM pequena, $20-50/mês em qualquer nuvem.
Créditos e quota de API do Apollo. A busca não gasta créditos. O enrichment sim — o Apollo cobra cerca de 1 crédito por email de negócio verificado e cerca de 8 créditos por número móvel revelado (junho de 2026). Um líder de RevOps enriquecendo 20-40 registros por semana fica folgadamente dentro de um allotment padrão de créditos; o perigo é o enrichment em bulk, que é exatamente por que ele tem gate. O Apollo também aplica rate limits de API por minuto, por hora e por dia que escalam com o plano (Free é 600 requests/dia; os tiers pagos vão mais alto) — leia seus limites exatos via o endpoint View API Usage Stats.
O custo de tokens do lado do Claude é dominado pelos payloads de resposta, então o scaffold enxuga os resultados de busca para id, nome, título, empresa e link antes de devolvê-los. Uma busca de 25 registros fica bem abaixo de 10K tokens; um par de buscas por sessão de prospecting por semana é dólares de um dígito/usuário/mês em cima da assinatura.
Como é o sucesso
Um sinal mensurável após um mês: o time-to-answer em “quem eu deveria estar trabalhando esta semana?” cai de “abrir o Apollo, remontar a busca salva, cruzar com sequences, exportar” (digamos cinco minutos ou mais) para “perguntar ao Claude, ler a resposta” (menos de um minuto). O sinal mais difícil de medir mas que carrega mais peso: o time para de enriquecer listas inteiras “por garantia” porque enriquecer os poucos registros que de fato vai tocar é agora o caminho de menor resistência — o consumo de créditos por reunião agendada cai, não sobe.
Versus as alternativas
A própria AI e buscas salvas do Apollo. First-party, sem processo para hospedar, e os dados já estão lá. Trade-off: você vive na UI do Apollo, e o assistente dele não consegue juntar os dados do Apollo com o resto do seu contexto de Claude. Escolha a UI nativa se seu time vive no Apollo; escolha este servidor se ele vive no Claude e quer uma única superfície de chat entre o prospecting e o servidor do Salesforce.
Um script descartável contra a REST API do Apollo. Controle máximo, manutenção máxima, e zero guardrails — cada time reconstrói auth, paging, o gate de créditos e o teto de inscrição na mão. Este scaffold te dá tudo isso em cerca de 400 linhas, e o kill-switch de créditos já vem cabeado.
Uma plataforma no-code (Clay, n8n). Excelente para pipelines de enrichment-e-routing agendados e produtizados. Forma diferente de “perguntar uma coisa ad-hoc para a qual eu não pré-montei um flow”. São complementos: Clay ou n8n para o waterfall recorrente, este servidor para a conversa. Se você quer a arquitetura por trás da versão recorrente, leia o primer de AI SDR.
Watch-outs
O README documenta isto em detalhe; a versão curta:
Dreno de créditos no enrichment. Um descuidado “enriquece todos esses” contra uma lista de 500 linhas pode gastar milhares de créditos em minutos. Guard: enrich_person está desligado a menos que APOLLO_ALLOW_CREDIT_SPEND=true, processa um registro por chamada, e força reveal_phone_number=false (o campo de alto custo) neste scaffold.
Inscrição em massa acidental.add_contacts_to_sequence dispara envios reais. Guard: a chamada recusa mais de 25 contatos (APOLLO_MAX_SEQUENCE_BATCH), exige uma caixa de envio nomeada, e demanda uma justificativa — não há caminho de “inscrever todo mundo”.
403 de master key numa sexta. As tools de sequences falham em silêncio se a key não for master. Guard: o scaffold pega o 403 e retorna uma mensagem clara nomeando o requisito de master key e onde gerá-la, em vez de um stack trace cru.
Fan-out de contatos duplicados. O Apollo não deduplica por padrão, então um create_contact repetido faz um segundo registro. Guard: run_dedupe vem em true por padrão.
Surpresas de rate limit. Um loop de paging em bulk pode bater no teto por minuto ou por dia do Apollo. Guard: cada busca tem teto de 100/página e o scaffold expõe o 429 explicitamente; adicione backoff (TODO #1 no README) antes de qualquer uso desassistido.
Stack
Apollo — base de dados de prospecting, contatos, sequences, enrichment
MCP Python SDK — o pacote mcp>=1.2.0; provê Server, stdio_server, e os decorators do registro de tools
httpx — cliente REST async contra api.apollo.io, autenticado com o header x-api-key
Claude Desktop ou Claude Code — interface de linguagem natural, chamador de tools
APOLLO_ALLOW_CREDIT_SPEND — o kill-switch no nível de env que decide se o enrichment dirigido por chat é permitido afinal
# 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())