A Model Context Protocol server that gives Claude scoped access to your Apollo account: prospect search against the Apollo database (no credits spent), search over your team’s saved Contacts, sequence listing, and per-sequence engagement stats — plus two gated writes and one credit-spending enrichment, each behind a justification. Drop it into Claude Desktop or Claude Code and your team can ask “which VPs of Sales at fintechs under 500 people aren’t in any sequence yet?” and “enrich this shortlist, justification: pre-call research for the Acme renewal” without leaving the chat — and without handing the model a button that burns credits or mass-enrolls a list. The complete scaffold lives in the artifact bundle at apps/web/public/artifacts/mcp-server-apollo-revops/, which ships a README.md, pyproject.toml, and src/apollo_revops_mcp/server.py ready to install with pip install -e ..
When to use this
Reach for this when prospecting and sequence triage have a clear weekly rhythm — build a list, check who’s already in a sequence, read reply rates, enrich the few records you’ll actually call — and the cost of bouncing between Apollo’s UI and your working doc for each question dominates the cost of the lookup itself. Two roles get the most from it. The RevOps lead who used to keep Apollo open in a tab now asks Claude in natural language and pastes a structured answer into a pipeline review. The GTM engineer who used to write a throwaway script against the Apollo REST API for every one-off list now has the four read endpoints already wrapped, with the credit-spending and send-triggering calls fenced off behind explicit gates.
It’s also the right pattern if you’ve already shipped the Salesforce RevOps MCP server and want the same shape of tools across your prospecting and system-of-record surfaces, so your team’s Claude prompts stay portable. Same response style, same justification posture on anything that writes or spends.
When NOT to use this
Skip it if any of the following hold:
You’re on a plan without advanced API access. Apollo gates advanced API access to the Organization plan ($119/user/month on annual billing, 3-seat minimum as of June 2026). The sequence tools also need a master API key — a standard key returns 403. If you can’t make a master key on an Organization-tier seat, the sequence half of this server won’t run; ship the prospecting subset only.
Compliance forbids prospect PII in a third-party LLM. Search results (names, titles, companies) are low-risk, but enrichment returns emails and — if you wire the webhook — phone numbers. If pushing prospect PII into an LLM is off the table, keep APOLLO_ALLOW_CREDIT_SPEND=false and use the server for list-building only, then reveal inside Apollo.
Your team sends from a sequencer that isn’t Apollo. If the actual outbound runs through Outreach, Salesloft, or Smartlead, the add_contacts_to_sequence and engagement tools point at the wrong system. Use the prospecting reads here and wire the send half to the tool that owns it.
You only have one or two prospecting questions a week. The amortized value sits below the setup and token cost. Stay with saved searches in the Apollo UI.
What it exposes
Seven tools, grouped by what they cost you.
Prospecting and reads (no credits):search_people hits POST /mixed_people/api_search — the API-optimized search that returns match metadata and spends no credits and reveals no emails. search_contacts hits POST /contacts/search for the people your team has explicitly saved.
Sequence reads (master key):list_sequences (POST /emailer_campaigns/search) and sequence_engagement (GET /emailer_messages/search), which aggregates message counts by status — delivered, opened, clicked, replied, bounced — for one sequence.
Credit-spending enrichment (gated):enrich_person (POST /people/match) consumes credits and is disabled unless APOLLO_ALLOW_CREDIT_SPEND=true, with a mandatory justification of at least 10 characters.
Writes (gated):create_contact (POST /contacts, run_dedupe defaulting true) and add_contacts_to_sequence (POST /emailer_campaigns/{id}/add_contact_ids), both requiring a justification; enrollment is hard-capped at 25 contacts per call.
There’s no delete tool, no bulk enrichment, no unbounded enrollment. The principle, same as the Salesforce server: every billable or irreversible action gets its own named button, never a free-text command.
Engineering posture
A few opinionated choices worth understanding before you adopt the scaffold.
Search is free; reveal is the cost line.search_people deliberately uses the credit-free mixed_people/api_search endpoint, which returns no contact details. Getting an email or phone is a separate enrich_person call. The split means Claude can build and refine a 200-person list at zero credit cost, and you only spend when you enrich the handful you’ll act on. Collapsing the two — revealing on every search — is how teams torch a credit balance in a morning.
Credit spend is a kill-switch, not a prompt.enrich_person checks APOLLO_ALLOW_CREDIT_SPEND before it runs. Off by default. The alternative — trusting the justification text alone — leaves the spend one confident misread away. The flag makes “are we allowing chat-driven enrichment at all?” an explicit deployment decision.
Enrollment is capped by construction.add_contacts_to_sequence refuses more than APOLLO_MAX_SEQUENCE_BATCH (default 25) contacts in a single call and requires a named send-from mailbox. Apollo’s endpoint will happily enroll hundreds; the cap keeps an accidental enrollment small enough to undo by hand.
Dedup defaults on. Apollo does not dedupe new contacts by default — a re-run with the same payload creates a second record. create_contact sets run_dedupe=true so a repeated call updates rather than fans out duplicates.
Cost reality
Three cost lines.
Claude subscription. Whatever you already pay for Claude Desktop or Claude Code (Pro at $20/user/month, Max tiers $100-200/user/month, or API consumption). The server doesn’t change this.
Self-host of the server. A local Python process per Claude Desktop user — zero infra cost on a laptop. Wrap it as a shared service and budget a small VM, $20-50/month on any cloud.
Apollo credits and API quota. Search spends no credits. Enrichment does — Apollo bills roughly 1 credit per verified business email and around 8 credits per mobile number revealed (June 2026). A RevOps lead enriching 20-40 records a week stays well inside a standard credit allotment; the danger is bulk enrichment, which is exactly why it’s gated. Apollo also enforces per-minute, per-hour, and per-day API rate limits that scale with plan (Free is 600 requests/day; paid tiers run higher) — read your exact limits via the View API Usage Stats endpoint.
Token cost on Claude’s side is dominated by response payloads, so the scaffold slims search results to id, name, title, company, and link before returning them. A 25-record search lands well under 10K tokens; a few searches per prospecting session per week is single-digit dollars/user/month on top of the subscription.
What success looks like
A measurable signal a month in: time-to-answer on “who should I be working this week?” drops from “open Apollo, rebuild the saved search, cross-check sequences, export” (call it five-plus minutes) to “ask Claude, read the answer” (under a minute). The harder-to-measure but more load-bearing signal: the team stops enriching whole lists “to be safe” because enriching the few records they’ll actually touch is now the path of least resistance — credit consumption per booked meeting goes down, not up.
Versus the alternatives
Apollo’s own AI and saved searches. First-party, no process to host, and the data’s already there. Trade-off: you live in Apollo’s UI, and its assistant can’t join Apollo data to the rest of your Claude context. Pick the native UI if your team lives in Apollo; pick this server if they live in Claude and want one chat surface across prospecting and the Salesforce server.
A throwaway script against the Apollo REST API. Maximum control, maximum maintenance, and no guardrails — every team rebuilds auth, paging, the credit gate, and the enrollment cap by hand. This scaffold gives you all of that in roughly 400 lines, and the credit kill-switch is already wired.
A no-code platform (Clay, n8n). Excellent for scheduled, productionized enrichment-and-routing pipelines. Different shape from “ask one ad-hoc question I didn’t pre-build a flow for.” They’re complements: Clay or n8n for the recurring waterfall, this server for the conversation. If you want the architecture behind the recurring version, read the AI SDR primer.
Watch-outs
The README documents these in full; the short version:
Credit drain on enrichment. A careless “enrich all of these” against a 500-row list can spend thousands of credits in minutes. Guard: enrich_person is off unless APOLLO_ALLOW_CREDIT_SPEND=true, processes one record per call, and forces reveal_phone_number=false (the high-cost field) in this scaffold.
Accidental mass enrollment.add_contacts_to_sequence triggers real sends. Guard: the call refuses more than 25 contacts (APOLLO_MAX_SEQUENCE_BATCH), requires a named send-from mailbox, and demands a justification — there is no “enroll everyone” path.
Master-key 403 on a Friday. The sequence tools silently fail if the key isn’t a master key. Guard: the scaffold catches 403 and returns a clear message naming the master-key requirement and where to generate one, instead of a raw stack trace.
Duplicate-contact fan-out. Apollo doesn’t dedupe by default, so a retried create_contact makes a second record. Guard: run_dedupe defaults to true.
Rate-limit surprises. A bulk paging loop can hit Apollo’s per-minute or per-day ceiling. Guard: every search caps at 100/page and the scaffold surfaces 429 explicitly; add backoff (TODO #1 in the README) before any unattended use.
Stack
Apollo — prospecting database, contacts, sequences, enrichment
MCP Python SDK — the mcp>=1.2.0 package; provides Server, stdio_server, and the tool-registry decorators
httpx — async REST client against api.apollo.io, authenticated with the x-api-key header
Claude Desktop or Claude Code — natural-language interface, tool caller
APOLLO_ALLOW_CREDIT_SPEND — the env-level kill-switch that decides whether chat-driven enrichment is allowed at all
# 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())