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 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:

  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.

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:

  1. Every org-scoped tool takes a required organization argument — the organization’s slug or id. It is injected into every tool’s input schema, so tools/list shows it; there is no default and no “current” organization.
  2. organizations_list is 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 as organization on every other call.
  3. organizations_manage returns 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 call organizations_list again — 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

ToolScope
organizations_listnone — any authenticated token
organizations_managenone — 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

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.

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_summary describes the organization as its worst domain (worst band, then lowest score), never an average, and always covers every domain.
  • domains come worst first, with domains that have no score yet last. Each has its score, band, coverage, taken_at, blocking_tasks and open_tasks, plus:
    • tasks: the open tasks (those not marked handled), most severe first, at most 10. tasks_truncated is true when there are more. Each is the full task, including where, records and link.
    • checks_failing: each failing check with what was measured beside its threshold, its basis, the provider rule for compliance checks, and either task_key (the task it owns) or covered_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, null score, coverage and taken_at, and its own next_nightly_at saying when its first score is due. It is never shown as zero.
  • With the tool’s domain argument set, only that domain is returned, and it gains a detail object with every check (any status, including unknown; checks SendOps cannot measure yet are left out, as in the API) and its latest_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

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_ownership_reportapi.contacts.view
contacts_copy_to_orgapi.contacts.manage
contact_list_linkapi.contacts.manage + api.topics.manage
contacts_set_list_membershipapi.contacts.manage + api.lists.view
activities_recordapi.activities.write
segments_authorapi.segments.manage
lists_manageapi.lists.manage + api.segments.view
topics_manageapi.topics.manage
attributes_manageapi.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

ToolScope
templates_listapi.templates.view
templates_renderapi.templates.view
templates_authorapi.templates.manage
templates_test_sendapi.templates.test
templates_image_reportapi.templates.view
assets_importapi.assets.manage + api.templates.view
workflows_get_performanceapi.workflows.view
workflows_dry_runapi.workflows.view
workflows_authorapi.workflows.manage
workflows_estimate_enrollmentapi.workflows.view
workflows_activateapi.workflows.manage
definitions_historyany 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:

  1. templates_image_report lists every template’s image_warnings and image_suggestions, worst first (only_broken: true keeps just the broken ones). With template, it returns that template’s image_notices, each with a fix naming 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.
  2. assets_import imports the template’s hot-linked images into the library, or just the urls named. convert_svgs: true (or svg_assets) converts the template’s SVG assets to PNG. Each row returns the new path, its CDN url and its {{asset}} reference, and the result carries the template’s content_hash and a ready-made replacements list. The template is not changed. It is the same import as POST /v1/assets/import: content-addressed, so importing the same image again returns unchanged: true. A call imports at most 20 images, and an image that cannot be fetched is a warnings entry while the rest still import.
  3. templates_author with action: "replace_images" takes replacements: [{from, to}] and a required expected_content_hash. It rewrites the stored body on the server and saves it like any other save, so you never re-send the body. from is an image URL exactly as the body has it, or a library path; to is the imported path or its whole reference token. A from that matches nothing is named in warnings, 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

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.

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

ToolScope
domain_auth_reviewapi.domain_auth.view
domain_auth_decide_sourceapi.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

ToolScope
api_key_requestsapi.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:

actionTakesReturns
requestname and scopes (required), expires_in_days (1–90, default 30), reasonThe pending request, plus a claim_token shown only now. No key exists yet.
statusrequest_idpending, approved (with the approved scopes and lifetime and the claim deadline), denied (with the admin’s reason), claimed or lapsed. Never a secret.
claimrequest_id, claim_tokenThe 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_credentials token has no person behind it and is refused with a tool error.
  • Secret-bearing results are never replay-stored. The request and claim results are kept out of the idempotency replay cache, so a secret never sits there at rest. The cost: a retried request makes a second request (bounded by the per-connection cap), and a retried claim is 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_stateWhat is true
deployedLive in SES and sendable now.
pendingSaved, 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.
failedThe content is stored, but the transform pipeline blocked the deploy. deploy_error says why.
not_applicableA 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. -32602 covers a missing resource under 2026-07-28; data.reason is 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.