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 more than 170 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 that many candidates. The MCP surface is instead 45 task-shaped tools, which is also the cap, 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:
- The resource is
https://mcp.sendops.dev. Send it as theresourceparameter on/oauth/authorize. A token minted for the Public API is refused here, and vice versa — same issuer, differentaud. - Discovery starts at a 401. An unauthenticated request returns
WWW-Authenticatepointing athttps://mcp.sendops.dev/.well-known/oauth-protected-resource, which nameshttps://auth.sendops.devas the authorization server. A client that has never heard of SendOps can bootstrap from that alone. - Register with CIMD if you are shipping software customers install. Publish a metadata document at an
https://URL and use that URL as yourclient_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.
Organizations
One connection can act in several of the user’s organizations. At consent the user ticks one or more; each tick is a grant, and the set of live grants for (client, user) is the connection’s organization set, re-read on every request. Nothing about it lives in the token: the access token still carries a single org_id (the first organization the user ticked), and on this surface that claim carries no privilege of its own.
Three consequences shape every integration:
- Every org-scoped tool takes a required
organizationargument — the organization’s slug or id. It is injected into every tool’s input schema, sotools/listshows it; there is no default and no “current” organization. organizations_listis the first call. It returns the organizations the connection covers and the scopes available in each. Its description tells the model to call it first and then ask the user which organization to use unless they have already said. Pass the slug asorganizationon every other call.organizations_managereturns a dashboard link (Profile → Connected Apps, deep-linked to this client) where the user adds or removes organizations for the connection. Show it, wait for the user to say they are done, then callorganizations_listagain — the new organization is live on the very next request, with the same token.
// organizations_list → structuredContent
{
"organizations": [
{ "id": "3f1c…", "slug": "acme", "name": "Acme", "role": "org_admin",
"scopes": ["api.contacts.manage", "api.contacts.view", "api.messages.view"] },
{ "id": "9b2e…", "slug": "acme-sandbox", "name": "Acme Sandbox", "role": "viewer",
"scopes": ["api.contacts.view", "api.messages.view"] }
]
} Scope is per organization
The user’s role can differ between organizations, and the connection’s access differs with it. tools/list is filtered on the union of the per-organization scopes — a tool appears if some organization the connection covers may run it — while tools/call is enforced against the named organization’s own scopes. In the example above, contacts_upsert is listed, succeeds with "organization": "acme", and is refused with insufficient_scope for "organization": "acme-sandbox", same token.
An unknown or missing organization
A call that names no organization, or one the connection does not cover, is a protocol error — the handler never runs:
{
"jsonrpc": "2.0", "id": 7,
"error": {
"code": -32602,
"message": "tool "contacts_upsert" needs an `organization` this connection covers; call organizations_list and pass the slug of the one to act in",
"data": { "reason": "unknown_organization", "tool": "contacts_upsert",
"organizations": ["acme", "acme-sandbox"] }
}
} data.organizations is the list the caller can name, sorted. The three inputs — absent, an unrecognised slug, and an organization the user never consented to for this client — are deliberately indistinguishable, so the refusal is never an oracle for which organizations exist. An organization that is present but not a string is invalid_arguments.
Revocation is per organization
Revoking one organization’s grant (from Connected Apps, or by an org admin) removes only that organization from the set; the token keeps working for the others. A connection whose set becomes empty — every grant revoked — is refused with 401 on its next request. Refresh tokens are bound to the first organization’s grant, so if that one is revoked while others remain, the current access token keeps working but the next refresh fails and the client re-authorizes.
`client_credentials` tokens have one organization
A client_credentials token acts as its own organization and nothing else. Its set has exactly one entry — the client’s organization, addressed by id, with no slug — so organization is still required and organizations_manage points at the org’s OAuth Clients settings rather than Connected Apps.
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.
Organizations
| Tool | Scope |
|---|---|
organizations_list | none — any authenticated token |
organizations_manage | none — any authenticated token |
The only two tools with no scope and no organization argument, pinned to exactly these two names by test. See Organizations above.
Sending readiness and deliverability
| Tool | Scope |
|---|---|
sending_readiness_check | api.account.view |
suppressions_check_address | api.suppressions.view |
suppressions_list | api.suppressions.view |
undeliverable_list | api.undeliverable.view |
reports_get_sending_health | api.reports.view |
messages_search | api.messages.view |
messages_diagnose | api.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.
The deliverability section
When the token also holds api.reports.view, sending_readiness_check adds a deliverability section: the Deliverability Score of each verified sending domain, rendered from the same data as the dashboard and GET /v1/deliverability, so the three cannot disagree. Without that scope the section is left out and listed in omitted.
org_summarydescribes the organization as its worst domain (worst band, then lowest score), never an average, and always covers every domain.domainscome worst first, with domains that have no score yet last. Each has itsscore,band,coverage,taken_at,blocking_tasksandopen_tasks, plus:tasks: the open tasks (those not marked handled), most severe first, at most 10.tasks_truncatedis true when there are more. Each is the full task, includingwhere,recordsandlink.checks_failing: each failing check with what wasmeasuredbeside itsthreshold, itsbasis, the providerrulefor compliance checks, and eithertask_key(the task it owns) orcovered_by(the task, owned by another check, that fixes it).dashboard_path: where a person acts on this domain in SendOps.
- A domain with no score yet has
status: not_yet_assessed, nullscore,coverageandtaken_at, and its ownnext_nightly_atsaying when its first score is due. It is never shown as zero. - With the tool’s
domainargument set, only that domain is returned, and it gains adetailobject with every check (any status, includingunknown; checks SendOps cannot measure yet are left out, as in the API) and itslatest_probe: what SendOps’s own receiver saw on the last message from that domain through your SES. The probe confirms the DKIM signature and the SPF and DMARC results; it does not say which folder a mailbox provider would use.
The section does not affect verdict or blockers: the verdict answers “is this account set up to send”, and a failing score is a different question. The tool’s own description tells an agent to read each task’s where before proposing anything (only sendops tasks might be done with other SendOps tools; dns, aws and third_party tasks go to a person with their records or link), to report a failing band with a blocking task first, and never to quote a score without its coverage.
The section is read-only. Re-checking a domain, running the authentication check and marking a task handled happen in the dashboard. Scores update nightly at 05:40 UTC and on re-check.
Audience
| Tool | Scope |
|---|---|
contacts_lookup | api.contacts.view |
lists_list | api.lists.view |
segments_list | api.segments.view |
segments_preview_predicate | api.segments.view |
audience_get_schema | api.attributes.view + api.topics.view |
contacts_upsert | api.contacts.manage |
contacts_archive | api.contacts.manage |
contacts_ownership_report | api.contacts.view |
contacts_copy_to_org | api.contacts.manage |
contact_list_link | api.contacts.manage + api.topics.manage |
contacts_set_list_membership | api.contacts.manage + api.lists.view |
activities_record | api.activities.write |
segments_author | api.segments.manage |
lists_manage | api.lists.manage + api.segments.view |
topics_manage | api.topics.manage |
attributes_manage | api.attributes.manage + api.activities.manage |
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.
contacts_ownership_report, contacts_copy_to_org and contact_list_link are for organizations on SendOps-owned contacts that share an AWS account’s SES contact list. The report separates the contacts an organization owns from probable copies brought in by the shared list, and only suggests: nothing is archived. contacts_copy_to_org copies (or, with archive_source, moves) contacts into another organization the connection covers, after a dry_run whose matched count the next call must confirm. Consent never relaxes in the copy. contact_list_link decides which one organization owns the shared list, by action: adopt, unlink, relink or move.
contacts_upsert accepts issue_visitor_id: true to return the contact’s website visitor id, and contacts_lookup reports the current one. The id is for the customer’s own server to set as a first-party cookie; an agent should only ask for it when it is about to hand the value to that server.
The four authoring tools each declare two scopes where one registry can reach another: lists_manage reads the segment registry to fill a List from a Segment, and attributes_manage selects between the attribute and activity-property registries on a kind argument. A tool listed to a credential that could not deliver one of its actions is worse than a tool that is absent.
Content — templates and workflows
| Tool | Scope |
|---|---|
templates_list | api.templates.view |
templates_render | api.templates.view |
templates_author | api.templates.manage |
templates_test_send | api.templates.test |
templates_image_report | api.templates.view |
assets_import | api.assets.manage + api.templates.view |
workflows_get_performance | api.workflows.view |
workflows_dry_run | api.workflows.view |
workflows_author | api.workflows.manage |
workflows_estimate_enrollment | api.workflows.view |
workflows_activate | api.workflows.manage |
definitions_history | any one of the seven kinds’ .view or .manage scopes |
Every scope on this surface is also enforced by a /v1 route — there are no MCP-only scopes. templates_author and PUT /v1/templates/{slug} are the same write behind two transports, as are workflows_author and POST/PUT /v1/workflows.
templates_author takes an action: save (the default), delete, make_default or replace_images. kind: layout saves a layout — a wrapper with exactly one {{> content}} slot, no subject, never deployed to SES and never sent on its own; the kind is fixed at create. make_default sets the organization’s default layout (a layout’s slug, or builtin), which has no /v1 route. delete is permanent and is refused for the default layout, for builtin, and for anything still referenced. templates_list rows carry kind, and layouts carry is_default.
Fixing a template’s images
The dashboard flags images a recipient will not see: an {{asset}} path the asset library does not hold, or an SVG asset, which Gmail and most Outlook versions do not show. It also flags images hot-linked from another site. An agent fixes them in three steps, one template at a time:
templates_image_reportlists every template’simage_warningsandimage_suggestions, worst first (only_broken: truekeeps just the broken ones). Withtemplate, it returns that template’simage_notices, each with afixnaming the call that resolves it. The counts are computed on each read and no URL is fetched, so a dead external link is not detected.assets_importimports the template’s hot-linked images into the library, or just theurlsnamed.convert_svgs: true(orsvg_assets) converts the template’s SVG assets to PNG. Each row returns the newpath, its CDNurland its{{asset}}reference, and the result carries the template’scontent_hashand a ready-madereplacementslist. The template is not changed. It is the same import asPOST /v1/assets/import: content-addressed, so importing the same image again returnsunchanged: true. A call imports at most 20 images, and an image that cannot be fetched is awarningsentry while the rest still import.templates_authorwithaction: "replace_images"takesreplacements: [{from, to}]and a requiredexpected_content_hash. It rewrites the stored body on the server and saves it like any other save, so you never re-send the body.fromis an image URL exactly as the body has it, or a library path;tois the importedpathor its wholereferencetoken. Afromthat matches nothing is named inwarnings, and if nothing changes, nothing is saved.
An asset_missing notice cannot be fixed this way on its own: there is nothing to import from. Ask the person for the image’s source URL, pass it in urls, and replace the missing path with the path it returns. For a one-shot alternative while writing a template, templates_author’s import_external_images: true imports every hot-linked image and rewrites the body as part of the save.
definitions_history is the one tool that spans kinds: it reads, compares and restores versions of segments, workflows, templates, attributes, activity properties, Lists and topics. It is listed to any caller who may read or write any versioned definition, and what it will actually do is decided per kind and per action against that organization’s own scopes.
`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
| Tool | Scope |
|---|---|
broadcasts_get_status | api.broadcasts.view |
broadcasts_compose | api.broadcasts.manage + api.lists.view + api.segments.view |
broadcasts_preview | api.broadcasts.view |
broadcasts_test_send | api.broadcasts.manage |
broadcasts_send | api.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.
To write a new email, broadcasts_compose takes body (the message, HTML with merge fields) and optionally layout (a layout’s slug, builtin, or omitted for the organization’s default). body_html is for a complete pre-rendered HTML document, and template for reusing an existing email template; exactly one of the three. data is the merge defaults whichever source you use. These are the body, html and template_id modes of POST /v1/broadcasts.
Domain authentication
| Tool | Scope |
|---|---|
domain_auth_review | api.domain_auth.view |
domain_auth_decide_source | api.domain_auth.manage |
domain_auth_review answers “what is sending as this domain, and should the policy move” in one call — the DMARC ladder with every criterion beside its measured value. domain_auth_decide_source records the verdict on one sending source. Neither publishes DNS; see Domain Auth.
API keys
| Tool | Scope |
|---|---|
api_key_requests | api.api_keys.request |
api_key_requests lets an agent ask for a new, narrower API key to hand to a sub-agent or another tool in the same organization. It is the MCP twin of the three /v1/api-key-requests endpoints, chosen by action:
action | Takes | Returns |
|---|---|---|
request | name and scopes (required), expires_in_days (1–90, default 30), reason | The pending request, plus a claim_token shown only now. No key exists yet. |
status | request_id | pending, approved (with the approved scopes and lifetime and the claim deadline), denied (with the admin’s reason), claimed or lapsed. Never a secret. |
claim | request_id, claim_token | The key and its secret, once. |
The rules are the API’s: scopes must be ones the connection already holds and never api.api_keys.request; a request lapses after 24 hours, an approval after one hour unclaimed; at most 3 pending requests per connection. The key is in the connection’s environment and is revoked automatically when the connection is disconnected.
Two things are particular to MCP:
- The requester is the connection’s grant in that organization. A
client_credentialstoken has no person behind it and is refused with a tool error. - Secret-bearing results are never replay-stored. The
requestandclaimresults are kept out of the idempotency replay cache, so a secret never sits there at rest. The cost: a retriedrequestmakes a second request (bounded by the per-connection cap), and a retriedclaimis refused because the request is already claimed.
There is deliberately no approve action. An owner or admin approves in the dashboard; see No tool weakens its own guardrail. The tool’s own description tells the agent to say a request is waiting for an admin rather than present it as a key, and to put the claimed secret straight into the sub-agent’s configuration without repeating it in its reply.
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.
A save and a deploy are two different facts
templates_author returns status: "saved" — an enum with exactly one value — and a separate deploy_state. Read the pair. The save is durable when the call returns; whether the template is sendable is the other field’s question, because it is about the template reaching the customer’s SES account:
deploy_state | What is true |
|---|---|
deployed | Live in SES and sendable now. |
pending | Saved, not yet in SES — the deploy ran out of the budget the save allows itself, or the org has no AWS connection yet. A background job is retrying; poll templates_list. |
failed | The content is stored, but the transform pipeline blocked the deploy. deploy_error says why. |
not_applicable | A layout. It is never deployed: it is sendable only as the wrapper of a composed broadcast. |
The two are separate so that “saved” can never be read as “ready to send”. An agent that reports a pending template as a shipped email is wrong in exactly the way this split exists to prevent, and the tool’s own result text says so. The same pair is on GET /v1/templates/{slug}.
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; a stale content hash, with the current one to retry against; a key already in use; a dependent that still points at the row you are deleting, named; a 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. For the same reason api_key_requests can ask for a key and claim one, but never approve one.
workflows_activate forces that gate on when the flow contains a step that sends email or calls a webhook — a send or a call webhook, at any depth. Both reach outside SendOps: one puts mail in front of a person, the other hands that person’s id, email and every attribute to a third-party system that may then message them itself. The confirmation prompt and the result say which half applied in the same words — Send approval is forced on: N steps that send email or call a webhook, or No step that sends email or calls a webhook: activation does not require send approval — so an agent that relays either tells the user the same thing. A flow whose only steps are add to list or set attr.x keeps whatever setting it had, because it produces nothing for anybody to approve. When the flow calls any webhook, the prompt additionally lists the endpoint keys it calls.
workflows_author writes call webhook "<key>" [as "<label>"] [{ failed: … }] like any other statement. The argument is an endpoint key, not a URL, and it must name a workflow webhook endpoint created in the dashboard under Connections → Webhooks — there is no tool that creates one, because the signing secret is shown once, at creation. An unknown key comes back as a named missing requirement rather than a saved flow that forks down its failed: arm for every contact. See Workflow webhook for what the endpoint receives.
workflows_dry_run takes webhook_outcome (ok | failed) alongside the contact. A call webhook step is simulated and never called — nothing is POSTed either way — so failed is how an agent traces the failed: arm without an endpoint that fails on demand.
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 bucket — ratelimit:mcp:{client_id}:{org_id} rather than the OAuth bucket /v1 uses.
The org_id in that bucket is the organization the call names. A tools/call is metered on the plan and bucket of its organization argument, so a connection covering two organizations on different plans gets each organization’s own limit, and exhausting one leaves the other callable. A call that names no organization the connection covers, or any method other than tools/call, is metered on the token’s first organization — and is then refused by dispatch, so a bad guess still costs one token there.
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 stale-hash conflict or a plan limit arrives. - Protocol errors — JSON-RPC errors. A malformed request, an unknown tool, an unsupported protocol revision, an organization the connection does not cover.
-32602covers a missing resource under2026-07-28;data.reasonis a constant —unknown_tool,insufficient_scope,invalid_arguments,unknown_organization,invalid_cursor,internal_error— so the classes are machine-distinguishable without matching message text.
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 45 tools — the number it currently holds — 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.
The catalogue is at its cap today; if it moves, new names are added and existing ones 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.