MCP servers

Sendoka ships two Model Context Protocol servers:

  • Keyless dev MCP (@sendoka/dev-mcp) — explore the API with no account: docs search, the OpenAPI surface, local validators, and dry-runs. Start here when you're learning or building.
  • Authenticated MCP (POST /api/mcp) — drive your workspace: send messages, query activity, check usage. Needs a sok_live_* / sok_test_* key.

An assistant can connect both at once: explore + validate keylessly, then switch to the authenticated server to actually send.

Keyless dev MCP

@sendoka/dev-mcp is a zero-dependency stdio server that exposes only public surface — the docs index, the published OpenAPI schema, and local validators. No key, no account, no workspace access, and it can never send. Safe to connect anywhere.

Install (keyless)

claude mcp add sendoka-dev -- npx -y @sendoka/dev-mcp@latest

For other MCP clients, point them at the stdio command npx -y @sendoka/dev-mcp@latest.

Check what you actually have. @latest resolves against npm, not this repo, so a tool documented here is only callable once that version is published. Ask the server: initialize reports serverInfo.version, and tools/list returns the tools that build really ships. platform_overview arrived in 0.2.0, lookup_error in 0.3.0, the schema-driven validate_payload in 0.4.0, and read_doc in 0.5.0. 0.6.0 changed no tool names: lookup_endpoint started honouring method, path and tag individually instead of ignoring a half-specified pair, and read_doc learned to tell "this deployment has no docs endpoint" apart from "no such guide".

Tools (keyless)

Tool What it does
platform_overview Start here. One-call functional map of the whole platform — capability areas (live from the API), the auth model, conventions, and the webhook event catalog.
search_docs Full-text search across the developer docs.
read_doc Read one guide in full, by slug or URL — the read half of search_docs.
lookup_endpoint Browse the public OpenAPI schema — list every operation or inspect one. method, path and tag each narrow on their own. Component schemas are inlined, so a request body arrives as fields rather than as a $ref.
lookup_error Decode an error code (SENDER_NOT_VERIFIED, TENANT_NOT_USABLE, …) — HTTP status, type, meaning, and how to recover. Local table, no network call.
generate_snippet Copy-paste code for a REST call (curl, node, python, go, ruby, php).
validate_payload Check a send body against published constraints locally — no API call.
lint_message Deliverability + compliance lints (TCPA opt-out, RFC 8058 unsubscribe, GSM-7 vs UCS-2, multi-segment SMS, spammy subjects).
normalize_phone / parse_email Pre-flight recipient values.
render_template_dry Preview a {{var}} template render without an API call.
dry_run_send One-shot pre-flight: validate + lint + recipient parse. Returns ready: true/false.

Typical flow: platform_overviewlookup_endpoint / search_docsread_docgenerate_snippetvalidate_payload / lint_message / dry_run_send — then switch to the authenticated MCP below to actually send. When a call comes back non-2xx, lookup_error decodes the code.

Source lives in the monorepo at packages/dev-mcp/. Override the target deployment with SENDOKA_DEV_BASE (defaults to https://www.sendoka.com).

Authenticated MCP

Sendoka ships a Model Context Protocol endpoint at POST /api/mcp so AI coding assistants (Claude Code, Cursor, etc.) can drive your workspace directly — send messages, query activity, check usage — without scraping the dashboard or generating REST calls.

The endpoint speaks the Streamable HTTP transport in stateless mode. JSON-RPC 2.0 over a single POST. No session state, no SSE.

Auth

Two ways to authenticate — both yield a Bearer token the MCP layer validates on every POST:

Browser login (OAuth 2.1) — recommended. A compliant client (Claude Code, claude.ai) reads the 401 discovery hint, registers itself (RFC 7591), opens /oauth/authorize in your browser where you log in and consent — choosing environment (test/live) and scopes — then exchanges a PKCE code at /api/oauth/token for a short-lived, scoped token. Nothing to copy or store.

Static key — paste a sok_live_* / sok_test_* key from the dashboard as the Bearer token.

Either way, each tool call forwards to the matching /api/v1/* endpoint with that token, so scope, rate-limit, idempotency, audit, and usage metering all apply exactly as for a direct REST call — there is no second control plane. (Under the hood, an OAuth access token is a short-lived, scoped API key, which is why the MCP layer treats both identically.)

Unauthenticated requests get a 401 with WWW-Authenticate: Bearer realm="Sendoka MCP", resource_metadata="…", which points at the RFC 9728 protected-resource metadata → the RFC 8414 authorization-server metadata, so clients discover the browser-login flow automatically.

Install in Claude Code

claude mcp add --transport http sendoka https://www.sendoka.com/api/mcp

On first use the client runs the OAuth browser-login flow — sign in and consent in your browser; there's no key to paste.

For platform mode, scope every call to a single tenant by adding the ref header (browser login still handles auth):

claude mcp add --transport http sendoka https://www.sendoka.com/api/mcp \
  --header "X-Sendoka-Tenant-Ref: tenant-external-id"

Scope per tool

Keys with null scopes (full access) can use every tool. Scoped keys can only use tools whose scope is granted.

Tool Scope
send_sms send:sms
send_email send:email
list_sms, list_emails, get_message read:messages
activity_summary read:events
usage_summary read:usage
list_contacts read:contacts
list_templates read:templates
validate_email validate:email
validate_phone validate:phone
list_audiences read:audiences
create_audience, send_audience write:audiences
list_suppressions read:suppressions
add_suppression, remove_suppression write:suppressions
get_contact read:contacts
create_contact, update_contact, delete_contact write:audiences
send_email_batch send:email
send_sms_batch send:sms
list_domains, get_domain, diagnose_domain read:domains
verify_domain write:domains
get_template read:templates
create_template, update_template, delete_template write:templates
list_phone_numbers, get_phone_number read:phone_numbers
provision_phone_number write:phone_numbers
list_messaging_pools, get_messaging_pool read:pools
create_messaging_pool write:pools
list_webhooks, get_webhook, list_webhook_deliveries read:webhooks
create_webhook, update_webhook, delete_webhook, replay_webhook_delivery, replay_failed_webhook_deliveries, test_fire_webhook write:webhooks
list_brands, get_brand read:brands
create_brand, update_brand write:brands
list_campaigns, get_campaign read:campaigns
create_campaign write:campaigns

Tools

send_sms

Send an SMS. Mirrors POST /api/v1/sms. Supports templates (template + variables), scheduling (scheduled_at or scheduled_local + scheduled_at_tz), tags, metadata, India DLT (dlt_template_id required for +91 destinations). Pass idempotency_key (forwarded as the Idempotency-Key header) so a retry after a transport error replays the original send within the 24h window instead of sending twice.

send_email

Send an email. Mirrors POST /api/v1/emails. Supports templates, attachments (base64 content or url), open/click tracking, scheduling, headers. Defaults to stream: "transactional" — set "broadcast" for list mail: it is what adds the one-click List-Unsubscribe header, and what a {{unsubscribe_url}} token in the body requires (on any other stream the token is refused with 422 UNSUBSCRIBE_REQUIRES_BROADCAST rather than rendered). Pass idempotency_key (forwarded as the Idempotency-Key header) so a retry after a transport error replays the original send within the 24h window instead of sending twice.

list_sms / list_emails

Paginated message lists for the key's environment. Compound cursor ({ISO}|{id}) — pass next_cursor from the previous response.

get_message

Single message by id. Requires both id and channel (sms or email) so the MCP layer can route to the right sub-resource.

activity_summary

Daily counts grouped by channel + status over the last N days (max 90). Same shape as GET /api/v1/activity.

usage_summary

Current period usage vs plan limits. Set by_key: true to include the per-API-key breakdown.

list_contacts / list_templates

Cursor-paginated lists. Useful for an assistant to discover what's available before composing a send.

validate_email / validate_phone

Syntactic + MX (email) / country detection (phone). Same as the REST /api/v1/validate/* endpoints.

list_audiences / create_audience

Manage audience definitions. create_audience takes a slug (kebab-case, unique within org/tenant) and name.

send_audience

Templated bulk send to every contact in an audience. Channel-aware (email or sms), supports ramp_minutes for paced delivery, subject_variants for deterministic A/B testing (email only), and scheduling. Capped at 10,000 recipients per send. Each contact's own fields are template variables ({{name}}, {{first_name}}, {{email}}, {{phone}}, and the same under {{contact.*}}). The call's variables override them and the contact's metadata overrides both. A placeholder no recipient resolves (a typo, usually) refuses the blast with 422 TEMPLATE_MISSING_VARIABLES; one that only some recipients resolve renders empty for the rest. Returns the job_id, counts of scheduled / suppressed and the delivery window. Pass idempotency_key (forwarded as the Idempotency-Key header): a retry replays the first blast's job_id instead of scheduling the list again, and 409 IDEMPOTENCY_IN_FLIGHT means that blast is still scheduling — retry later with the same key.

list_suppressions / add_suppression / remove_suppression

Manage the suppression list. add_suppression takes channel, value, and an optional reason (manual, unsubscribe, bounce, complaint, stop). Tenant-bound keys see and edit only their own tenant's rows plus platform-wide ones. remove_suppression can only remove bounce, manual and unsubscribe rows: a stop or complaint row is the recipient's own opt-out and comes back as 409 SUPPRESSION_PROTECTED — the tool description says so, and an agent should report it rather than retry.

Contacts (write) — get_contact / create_contact / update_contact / delete_contact

CRUD over contacts. create / update take email and/or phone, name, metadata, and audience slugs; update_contact.audiences replaces the full membership set. Mutations require write:audiences.

Domains — list_domains / get_domain / verify_domain / diagnose_domain

Inspect and verify sending domains. verify_domain re-checks DNS against SES (safe to repeat); diagnose_domain runs a DKIM/SPF/DMARC/MX deliverability check. Call list_domains (or get_context) before send_email to confirm the from-domain is verified.

Batch send — send_email_batch / send_sms_batch

One sender + body fanned out to up to 100 recipients, each as its own private envelope + message id. Mirror POST /api/v1/{emails,sms}/batch. send_email_batch carries the same stream field, applied to every fanned-out envelope — a blast is list mail, so pass "broadcast". The bulk shape has no track_opens / track_clicks, so nothing sent through it is open- or click-tracked. Both accept idempotency_key (forwarded as the Idempotency-Key header): a retry within the 24h window replays the recorded per-item results instead of re-sending the blast. A replay is not a re-attempt — items that failed the first time stay failed, so retry just those as a new batch under a new key. For saved groups or per-recipient variables, use send_audience.

Templates (write) — get_template / create_template / update_template / delete_template

Full template lifecycle. create_template fixes channel (email|sms) and a kebab-case slug; update_template patches content (pass html:null / text:null to clear). Slugs flow into the template arg on the send tools.

Phone numbers — list_phone_numbers / get_phone_number / provision_phone_number

SMS sender setup. provision_phone_number branches on kind: number allocates a real US/CA number (longcode / tollfree / shortcode, optional 10DLC campaign_id); alphanumeric registers a sender ID for UK/AU/EU. Provisioning a real number hits AWS and costs money.

Messaging pools — list_messaging_pools / get_messaging_pool / create_messaging_pool

Named groups of registered numbers. The pool slug becomes the from_pool value on send_sms / send_audience; Sendoka picks the best number per recipient.

Webhooks — list_webhooks / get_webhook / create_webhook / update_webhook / delete_webhook / list_webhook_deliveries / replay_webhook_delivery / replay_failed_webhook_deliveries / test_fire_webhook

Manage event subscriptions and debug delivery. create_webhook returns the signing secret once. list_webhook_deliveries surfaces non-2xx responses and exhausted retries; replay_webhook_delivery re-sends a single past delivery (whd_...); replay_failed_webhook_deliveries recovers a whole backlog (100/call, oldest first, optional since); test_fire_webhook sends a synthetic signed fixture so an agent can verify the user's receiver end-to-end.

10DLC brands + campaigns — list_brands / get_brand / create_brand / update_brand / list_campaigns / get_campaign / create_campaign

US A2P registration. Register a brand, then a campaign under it (use_case + a description of ≥ 40 chars + sample_messages), then attach numbers for longcode throughput. Both registrations are async — poll status via the matching get_* tool.

The registry vets a brand on its legal identity, so create_brand takes the full block — legal_company_name, entity_type, the registered address and an authorized contact — not just a display name. Read provider_missing_fields on the response: [] is complete, non-empty names what the registry demanded and this submission lacked, null means the registry's field list was unreadable so nothing is known. update_brand is the fix for a non-empty list — it re-files the registration already held rather than minting a second billed one, and its resubmit field says whether the registry actually took the change.

create_campaign takes the carrier consent block as well as the use case — message_flow (where consent was captured and what the user agreed to), opt_in_keywords, opt_in_message, help_message, privacy_policy_url, terms_url, the embedded_link / embedded_phone / age_gated / direct_lending flags, and opt_in_evidence_url. US 10DLC has required a described opt-in flow at the registry since 2026-06-30, and a toll-free verification is largely made of these fields. Unlike a brand, a campaign has no PATCH on either surface: a non-empty provider_missing_fields means deleting it and registering again.

With a test key none of these reach the registry: brands, campaigns and numbers all come back verified immediately with test-reg-… identifiers. That exercises the chain without spending money, but it cannot tell you whether a real brand would be approved, and a test-mode resource cannot back a live one.

Not exposed over MCP (by design): API-key management, tenant suspend/unsuspend, and webhook secret rotation. These are credential- or platform-destructive operations — perform them via the dashboard or a direct REST call with a narrowly-scoped key.

Discovery

GET /.well-known/oauth-protected-resource returns RFC 9728 metadata so MCP clients can auto-detect the auth scheme:

{
  "resource": "https://www.sendoka.com/api/mcp",
  "authorization_servers": ["https://www.sendoka.com"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["send:email", "send:sms", "..."],
  "resource_name": "Sendoka MCP"
}

authorization_servers: [] signals "no OAuth flow — bring your own static bearer token from the dashboard." 401 responses include a WWW-Authenticate header pointing at this document so compliant clients can wire the discovery flow automatically.

Protocol details

  • Protocol version: 2025-06-18 (negotiated — on initialize the server echoes the client's requested version when it's one of 2025-06-18, 2025-03-26, 2024-11-05, otherwise it answers with 2025-06-18).
  • Transport: Streamable HTTP, stateless
  • GET /api/mcp returns 405 — we don't open server-initiated streams. Clients fall back to plain request/response.
  • CORS: the OPTIONS preflight and every POST/GET response carry Access-Control-Allow-Origin: * with WWW-Authenticate exposed, so browser-based MCP clients can connect and read the 401 discovery hint (auth still required on POST).
  • Structured output: a tools/call result includes structuredContent only when the upstream body is a JSON object; the full payload is always in the text content block.
  • Timeouts: the internal self-call to /api/v1/* is bounded at 60s — a hung upstream returns isError: true with MCP_UPSTREAM_TIMEOUT instead of stalling the request.
  • Errors: tool failures return a JSON-RPC result with isError: true and the upstream HTTP status in _meta.upstream_status. Protocol-level errors (parse, invalid request, unknown method) use the standard JSON-RPC error codes.

Adding new tools

Edit src/app/api/mcp/tools.ts. Each tool entry needs a name, description, inputSchema (JSON Schema), and a handler that calls forward(req, method, path, body) against the v1 route. Re-use the existing v1 endpoint instead of touching service code directly — that's what keeps the auth + observability surface coherent.