---
title: "MCP server, SDK or REST API: how should an AI agent send email?"
description: "The same email send over MCP, an SDK and REST: when each fits, OAuth vs API keys, and why scopes, limits and errors match on all three."
url: https://www.unitpost.com/blog/agents/mcp-vs-api-for-agent-email
section: Blog
updated: 2026-11-02
---
# 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.

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

> The same email send over MCP, an SDK and REST: when each fits, OAuth vs API keys, and why scopes, limits and errors match on all three.

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:

```bash
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 (/blog/agents/ai-agent-approval-before-send).
- 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.

## FAQ

### 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.

## Related

- [Move off SendGrid SMTP after the free plan ended](https://www.unitpost.com/blog/concepts/move-off-sendgrid-smtp): SendGrid retired its free plan. Point the same SMTP client at Unitpost — host, username unitpost, API key as password — without rewriting your app.
- [Send email and SMS from an n8n AI agent with MCP](https://www.unitpost.com/blog/agents/n8n-ai-agent-email-sms): Connect an n8n AI Agent to Unitpost with the MCP Client Tool and a Bearer API key, or call the REST API from an HTTP Request tool.
- [Personalize onboarding emails with an LLM without losing control](https://www.unitpost.com/blog/agents/personalize-onboarding-email-llm): Keep the onboarding template fixed and reviewed, let an LLM fill named variables from product data, check each value, and send once with an idempotency key.
