MCP Server

Search Documentation

Search across all developer documentation

MCP Server

SendOps speaks the Model Context Protocol, so an AI agent — Claude, Cursor, an agent you built — can read and act on a customer’s email infrastructure directly.

https://mcp.sendops.dev/mcp

This is not a second API. It is the same service layer the Public API runs on, reached over a different transport, guarded by the same api.* scopes, metered by the same limiter, and written to the same audit log.

What is different is the shape. The Public API has north of eighty operations; one tool per route is the failure mode most MCP servers ship with, because every tool description occupies the model’s context on every turn and selection accuracy collapses long before eighty candidates. The MCP surface is instead 30 task-shaped tools, capped at 34, each answering a question someone actually asks.

Connecting

Transport

POST only, at https://mcp.sendops.dev/mcp. The server is stateless: every request is self-contained, there is no Mcp-Session-Id, no SSE stream to hold open, and no session to terminate. GET and DELETE answer 405.

It is built for MCP revision 2026-07-28 and also serves 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. Call server/discover to read the live list rather than assuming this one — nothing hand-writes it, so it is always what the transport actually serves.

JSON-RPC batching is refused at every revision

A top-level JSON array is rejected regardless of the protocol version you declare. MCP removed batching in 2025-06-18; we refuse it below that too, because metering charges per HTTP request and a batch would let one request carry five mutations against a budget of one.

Authorization

OAuth 2.1 only. API keys (sk_live_ / sk_test_) are refused here — twice, and deliberately: a bearer credential with no user, no consent and no audience binding does not belong on an agent surface. A key sent to this host gets 400 wrong_host, and would be refused again at the token layer.

Read the OAuth 2.1 guide for the full flow — specifically the authorization-code half, since this surface acts for a person and client_credentials has no user behind it. Three things are specific to MCP:

  1. The resource is https://mcp.sendops.dev. Send it as the resource parameter on /oauth/authorize. A token minted for the Public API is refused here, and vice versa — same issuer, different aud.
  2. Discovery starts at a 401. An unauthenticated request returns WWW-Authenticate pointing at https://mcp.sendops.dev/.well-known/oauth-protected-resource, which names https://auth.sendops.dev as the authorization server. A client that has never heard of SendOps can bootstrap from that alone.
  3. Register with CIMD if you are shipping software customers install. Publish a metadata document at an https:// URL and use that URL as your client_id — no ticket, no admin, no allocated credentials.

server/discover sits behind the same auth as everything else. There is one auth rule for this host, not two.

curl -X POST https://mcp.sendops.dev/mcp \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

A liveness probe is available unauthenticated at GET https://mcp.sendops.dev/mcp/_health. It reports the running build and nothing org-scoped.

The tool surface

tools/list is scope-filtered: a client sees only the tools its token can actually call. Two customers with different grants see different servers, and a tool absent from the list is not merely hidden — calling it is refused too, re-checked against the same scopes rather than trusting the list.

Read the list, don't hardcode it

Exact names, input schemas, output shapes and annotations are served live by tools/list, in full JSON Schema 2020-12. Tools are added over time; the groups below are the map, not the contract.

Sending readiness and deliverability

ToolScope
sending_readiness_checkapi.account.view
suppressions_check_addressapi.suppressions.view
suppressions_listapi.suppressions.view
undeliverable_listapi.undeliverable.view
reports_get_sending_healthapi.reports.view
messages_searchapi.messages.view
messages_diagnoseapi.messages.view

sending_readiness_check is the one to reach for first: it answers “can this org send, and if not, what is blocking it” across the AWS connection, identity verification, the SES sandbox and tracking configuration, in one call.

Audience

ToolScope
contacts_lookupapi.contacts.view
lists_listapi.lists.view
segments_listapi.segments.view
segments_preview_predicateapi.segments.view
audience_get_schemaapi.attributes.view + api.topics.view
contacts_upsertapi.contacts.manage
contacts_archiveapi.contacts.manage
contacts_set_list_membershipapi.contacts.manage + api.lists.view
activities_recordapi.activities.write

segments_preview_predicate compiles and sizes a SendQL predicate without saving it — the loop an agent needs before proposing a segment. audience_get_schema returns the attribute and topic registry a predicate may reference, which is what stops a model inventing field names.

Content — templates and workflows

ToolScope
templates_listapi.templates.view
templates_renderapi.templates.view
templates_authorapi.templates.manage
templates_test_sendapi.templates.test
workflows_get_performanceapi.workflows.view
workflows_dry_runapi.workflows.view
workflows_authorapi.workflows.manage
workflows_estimate_enrollmentapi.workflows.view
workflows_activateapi.workflows.manage

api.templates.manage, api.templates.test and api.workflows.manage are MCP-only — there is no /v1 write endpoint for a template or a workflow. An API key may carry them, but nothing will read them.

`api.templates.test` sends real mail

It reads like a harmless verb. templates_test_send puts a real message through the customer’s SES and into a real inbox, which is why it is classified as a mutating scope and why the tool cannot claim to be read-only.

Broadcasts

ToolScope
broadcasts_get_statusapi.broadcasts.view
broadcasts_composeapi.broadcasts.manage + api.lists.view + api.segments.view
broadcasts_previewapi.broadcasts.view
broadcasts_test_sendapi.broadcasts.manage
broadcasts_sendapi.broadcasts.manage

broadcasts_compose requires the two audience read scopes alongside broadcasts.manage, and that is mandatory rather than defensive: the tool resolves an audience across both namespaces and its miss branch names real List and Segment keys, so declaring only broadcasts.manage would hand a credential the org’s audience catalogue from one call with a bad audience name.

Guardrails

These are properties of the surface, not advice. An integration does not opt in and cannot opt out.

Two tools take two round trips

broadcasts_send and workflows_activate do not complete in one call. The first call returns what is about to happen plus an input request the client must answer; only a call carrying a valid confirmation proceeds.

{
"_meta": { "dev.sendops/idempotencyKey": "018f...-a1" },
"name": "broadcasts_send",
"arguments": { "...": "..." }
}

The confirmation travels as a signed token the client holds — the retry may land on any instance, so nothing server-side can remember it. The signature covers the tool, the organization and the arguments, so a confirmation minted for a small test audience cannot be redeemed for the whole list. Any verification failure is one refusal with one remedy: start again.

Reversible writes deliberately do not confirm. A prompt on every write trains people to click through prompts, which devalues the two that matter.

Every mutating call is idempotent, whether or not you ask

Supply a key in the call’s _meta as dev.sendops/idempotencyKey and it is honoured for 24 hours. Supply nothing and a key is derived from the call — tool name, canonicalised arguments, request state, input responses — for 5 minutes.

The derived layer exists because most MCP clients cannot set _meta on a tool call at all. Explicit-only protection would cover exactly the integrations that read this page and none of the agents it is about.

The cost is stated plainly: a derived key cannot tell a retry from a deliberate repeat. An identical mutating call within five minutes is answered from the first one’s result, stamped dev.sendops/idempotentReplay: true so a replay reads as one in a transcript. The escape hatch is exact — name a fresh explicit key, which uses a different bucket entirely.

Git ownership means two different things

A customer’s repo is the source of truth for anything git-backed, and the response tells you which of two things happened:

ResourceGit-connected org
Topic, List, attribute, activity propertythe write is refused — the repo wins
Templatethe save is routed to a pull request

These must be distinguishable from the response alone. Otherwise an agent either retries a refusal pointlessly, or reports a pending PR as a completed change. A pending change is never phrased as a completed one: you get the PR URL and a statement that it is awaiting review.

A test credential is refused the destructive half

A token from a test client (oc_test_) is refused the destructive operations, the same as on the Public API, and is throttled to 10% of the org’s limit.

A refusal names its cause and what to do instead

Insufficient scope and which one; git-owned and therefore read-only; org policy requires a PR; stale content hash; plan limit reached. A large share of write attempts are refused for reasons an agent can work around if told — a bare failure just sends it round the same loop.

No tool weakens its own guardrail

There is no tool to turn off a workflow’s send approval, and none will be added. An agent may take a consequential action; it may not remove the check on that action.

Data an agent does not get

The MCP surface returns a wider projection than /v1 in some places and a narrower one in others, deliberately. Two rules to design around:

  • Counts and samples beat rows. Tools return aggregates and bounded samples rather than full result sets, because a model reasoning over 50,000 rows is both expensive and worse at the task than one reasoning over a count and twenty examples.
  • Truncation is always stated. A tool that returned a page never describes it as the whole set. Where a result is partial, the response says so and says what the total was.

Rate limits

MCP callers are metered on the same per-org, per-minute plan limit as the Public API, but from a separate bucketratelimit:mcp:{client_id}:{org_id} rather than the OAuth bucket /v1 uses.

That split is the point: an agent’s exploratory burst must not be able to exhaust the same customer’s server-to-server API budget, and an operator reading a 429 should be able to tell from the key which surface spent it.

Responses carry the same RateLimit-* / X-RateLimit-* headers described in Rate Limiting. A refused batch still costs one token.

Errors

Two layers, and the distinction is load-bearing for an agent:

  • Tool errors — the tool ran and has something to tell the model. Returned as a tool result with isError, carrying text the model is expected to read and act on. This is where a scope refusal, a git-ownership refusal or a plan limit arrives.
  • Protocol errors — JSON-RPC errors. A malformed request, an unknown tool, an unsupported protocol revision. -32602 covers a missing resource under 2026-07-28.

HTTP-layer refusals (401, 403, 429, 400) are RFC 7807 problem documents, the same shape as the Public API’s.

Versioning

The tool catalogue is capped at 34 tools, and renaming a tool is a breaking change — a client’s prompt, its allow-list and its cached tools/list all key on the name. Names are lower snake_case with the domain first and no dots (broadcasts_send, not broadcasts.send), because a gateway forwarding MCP tools to a model API forwards the name verbatim and dotted names are rejected there.

New tools are added; existing names and their guarantees are not repurposed. Read tools/list at startup rather than pinning a list at build time, and read server/discover for the revisions and capabilities of the build you are actually talking to.