MCP server, SDK or REST API: how should an AI agent send email?

Use MCP when a person works in an MCP client such as Claude Code or Cursor and the model should choose the calls. Use an SDK or one REST tool when your own agent code decides what gets sent, and the CLI or REST in CI. The choice changes who decides and how you sign in, not the rules: every Unitpost MCP tool is one REST call, with the same scopes, rate limit, idempotency and errors.

Unitpost ·

An agent can reach Unitpost three ways: through the MCP server, through an official SDK, or with plain HTTP calls to the REST API. They end at the same endpoint. What differs is who decides what gets sent, the model or your code, and how the caller signs in.

What does the same send look like three ways?

Over REST it is one POST to /api/v1/email with a Bearer key, a User-Agent and a JSON body. Over MCP you add the server once; the agent then calls email_send with the same fields under body, and the server turns that into the same request.

Bash
# MCP: add the server once, then approve in the browser from /mcp
claude mcp add --transport http unitpost https://mcp.unitpost.com/mcp

# REST: the call every MCP tool and SDK method ends in
curl https://www.unitpost.com/api/v1/email \
  -H "Authorization: Bearer $UNITPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "User-Agent: my-app/1.0" \
  -d '{
    "from": "Acme <you@yourdomain.com>",
    "to": "customer@acme.com",
    "subject": "Welcome aboard",
    "html": "<p>Thanks for signing up.</p>"
  }'

The SDK wraps the same request and returns { data, error } instead of throwing:

cURL
import { Unitpost } from "unitpost";

const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);

const { data, error } = await unitpost.email.send({
  from: "Acme <you@yourdomain.com>",
  to: "customer@acme.com",
  subject: "Welcome aboard",
  html: "<p>Thanks for signing up.</p>",
});

if (error) throw error;
console.log(data.id);

All three answer with the email's id and a status of queued. Over MCP, the same object comes back under data.

When does MCP fit?

When a person is working in an MCP client and wants the model to drive: Cursor, Claude Code, Claude Desktop, ChatGPT, Codex, Copilot in VS Code, the Gemini CLI, Windsurf. There is nothing to deploy. The server is hosted at https://mcp.unitpost.com/mcp, speaks streamable HTTP, and lists one tool per API operation, so the same connection can send a test, check a domain or draft a campaign.

The trade-off is that the model picks the arguments. Any check you want has to live in the client's approval prompt or in the credential's scope. ?toolsets=sending shrinks the tool list to sending and its read-backs, which keeps the model's context small, but listing is not a permission: an unlisted tool still runs if the credential allows it. ?readonly=1 is different, because the server enforces it and refuses every write.

Agent frameworks can be MCP clients too. The Vercel AI SDK's createMCPClient, the OpenAI Agents SDK's MCPServerStreamableHttp and the Claude Agent SDK's mcpServers all connect with a key.

When does an SDK or a REST tool fit?

When your own code runs the agent and should decide what goes out. Give the model one tool, send_email, and let your code hold the key, check the recipient against an allowlist, choose the idempotency key and hand back either the result or the error envelope. The model never sees the credential, and it has one tool in context instead of the full list.

Official SDKs exist for Node.js, Python, Ruby, PHP, Laravel, Go, Java, Rust and .NET. They generate an idempotency key when you don't pass one and retry a 429, a 5xx or a timeout without sending twice. Pass your own key, derived from the task, when two separate calls must dedupe against each other. If you want no dependency at all, the same tool can POST to /api/v1/email with any HTTP client; our framework guides show both versions side by side.

What about CI and scripts?

No person and no chat, so no MCP client. Use the CLI or REST with a key in the environment. unitpost email send wraps the same call, prints one JSON document when output is piped or CI is set, and exits 0 on success, 1 when the request failed and 2 when the command itself was wrong. unitpost doctor makes a good first step in a job: it exits non-zero when the credential or sending domain is off. The CLI docs put the split plainly: prefer MCP for tool calls inside a chat session and the CLI for shells, scripts and CI.

OAuth or an API key?

OAuth 2.1 for clients a person uses. Add the server URL and the client opens Unitpost in the browser: pick a workspace, approve the scopes, and you're connected. There is no key to copy, access tokens last an hour and refresh on their own, and each sign-in is listed under Settings, Connected apps, where you can revoke it. unitpost login uses the same flow.

An API key for code and CI. A pk_live_… key is shown once and stored only as a hash. Pick Full access or Sending on any plan, or Read only or a custom capability list on a paid plan. MCP clients that can only paste a key use the same one as a Bearer header.

Both resolve to one workspace and one set of capabilities, and both go through the same check. A call outside the grant is 403 insufficient_scope whichever credential made it.

What stays the same on all three?

The MCP server doesn't reimplement anything. Each tool call becomes a request to the same route handler a REST client hits, so:

  • Scopes. A credential without emails:send can't send, on any path.
  • Rate limits. One budget per workspace, 10 requests a second by default, shared by every key, the MCP server, the CLI and OAuth apps. Over it you get 429 rate_limit_exceeded with Retry-After; an MCP tool error carries the same wait as retry_after_seconds.
  • Idempotency. The Idempotency-Key header on REST, the idempotencyKey option in the Node.js SDK, and an idempotency_key input on MCP tools, because a tool call has no headers. Same 24-hour window, same 409 when one key arrives with a different body.
  • The sending gate. A verified from domain, suppression and your plan's quota.
  • Errors. { "error": { "code", "message", "details" } } everywhere; over MCP it arrives under data with a hint for the next step.
  • Records. MCP sends land in Activity and fire the same webhooks.

One mechanic does differ: who retries. An SDK retries for you; an MCP agent has to reuse its own idempotency_key when it tries again, so say so in its instructions.

Which should you pick?

  • A person working in Cursor, Claude Code or ChatGPT: MCP with OAuth.
  • Your product's own agent: one REST or SDK tool, a send-only key in server code, an allowlist and an approval step.
  • CI and scripts: the CLI with a key in the environment.

Mixing them is fine. They share one workspace, one rate limit and one activity log.

Frequently asked questions

Can the MCP server do anything the REST API can't?

No. It has one tool per public API operation, plus helpers such as docs_search, and nothing is MCP-only. If your tool doesn't speak MCP, the REST API or an SDK reaches the same surface.

Does MCP cost extra?

No. The MCP server is included on every plan, the free plan too, and you pay only for the email you send. Tool calls that reach the API count against the same per-workspace rate limit as REST calls; the docs and design-library tools don't.

Should my own server-side agent sign in with OAuth?

Use an API key. OAuth is built for a person approving a client in the browser. Code you deploy holds a key in its environment, scoped to what it needs, and you can revoke that one key in the dashboard without touching anything else.

Send email your users actually receive

5,000 emails a month free — API, SMTP, templates, tracking. No credit card.