Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

Build a Sinch Agent Tools MCP Server for SMS, Voice & Messaging Automation in 2026

Sinch's August 2026 Agent Tools suite put SMS, WhatsApp, voice, and OTP verification inside AI coding agents via MCP. This guide builds a production FastMCP 2.x TypeScript server with Zod schemas, retry handling, delivery-status polling, and security configs for Claude Desktop and Cursor.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 11, 2026 Published
|
Aug 11, 2026 Updated
|
16 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Sinch Agent Tools (August 2026) ships an MCP server, Skills files, CLI, SDKs, and Functions that connect agents to live SMS, WhatsApp, voice, and verification APIs.
  • A FastMCP 2.x TypeScript server centralizes Sinch's dual auth model: service-plan ID + API key for SMS XMS and OAuth client-credentials for WhatsApp, Voice, and Verification.
  • Exponential retry with jitter, OAuth token caching, and delivery-status polling turn rate limits and duplicate-send risks into handled edge cases.
  • Hard security rules keep keys out of prompts, egress-allowlist the exact Sinch domains, and never log OTP codes or message bodies.

In August 2026, Sinch launched Agent Tools, a developer toolkit for the agentic era: an MCP server, Skills files, a CLI, SDKs, serverless Functions, and editor plugins that put Sinch's communications APIs directly into your editor, your terminal, and your agent's workflow. The headline piece is the MCP server, published as @sinch/mcp on npm, which connects AI agents and coding tools to live Sinch APIs, Messaging (SMS, WhatsApp, RCS, email), Voice, Verification, and Numbers, so the code agents write matches how Sinch actually behaves. It works with Claude, Cursor, and any MCP-compatible tool, and critically, this is not a new SDK wrapped in MCP branding: the agent calls live APIs and gets real responses, which is exactly what changes what you can automate. We catalogue precisely this class of integration in the MCP directory.

What this guide builds. You will build a production-grade FastMCP 2.x TypeScript server, sinch-communications, exposing six tools: send_sms, send_whatsapp, check_delivery_status, initiate_voice_call, send_otp, and verify_otp, using Sinch credentials (service-plan ID plus API key for SMS, project ID plus key ID plus key secret for WhatsApp and Voice), Zod 4 input schemas, rate-limit and delivery-status handling, and hard security rules. When we shipped this at SaaSNext for a logistics client that needed booking confirmations and OTPs, the failure mode that bit us first was retrying sends on timeout and creating duplicate messages; idempotency and delivery-status polling fixed it, and those lessons are baked into the code below.

Why Sinch Agent Tools matters in 2026

Communications is the highest-frequency integration in modern software, and it is the one agents have struggled to reach safely. You could string together REST calls, but the agent would hallucinate auth patterns and endpoints. Sinch's answer has three parts. First, the MCP server exposes 15 tools across five areas: Messaging, Voice, Verification, Email, and Numbers, and the tools can be filtered by tag so a conversation-focused agent only loads what it needs into context. Second, Sinch Skills are structured knowledge files following the open Agent Skills standard, giving agents expert-level context on auth methods, regional endpoints, and the gotchas that break generated code, loaded on demand at well under 5,000 tokens. Third, the suite ships a CLI, SDKs in Node.js, Java, .NET, and Python, and serverless Functions, so the same authentication model carries from your agent into production code.

There are two deployment models: a Sinch-hosted cloud endpoint with no local setup, ideal for agents running inside chat interfaces, and a self-hosted server, which is better for IDE-native workflows in Cursor or Claude Code and for regulated environments that need data sovereignty. The self-hosted option is what this guide builds. Either way, the moment an agent can send an SMS, trigger a TTS callout, or complete a phone verification mid-task, support automation, appointment reminders, and fraud-resistant onboarding become agent capabilities rather than manual tickets.

Architecture: one server, five Sinch surfaces

flowchart LR
    subgraph Client["AI Client"]
        C1["Claude Desktop"]
        C2["Cursor IDE"]
    end
    subgraph Svr["sinch-communications (FastMCP 2.x TypeScript)"]
        T1["send_sms"]
        T2["send_whatsapp"]
        T3["check_delivery_status"]
        T4["initiate_voice_call"]
        T5["send_otp"]
        T6["verify_otp"]
    end
    subgraph SVC["Sinch APIs"]
        X["XMS SMS API (service plan + API key)"]
        CV["Conversation API WhatsApp (OAuth)"]
        VC["Voice calling API (TTS callout)"]
        VF["Verification API (OTP)"]
    end
    C1 --> Svr
    C2 --> Svr
    T1 --> X
    T3 --> X
    T2 --> CV
    T4 --> VC
    T5 --> VF
    T6 --> VF

The server authenticates differently per surface because Sinch does: SMS uses the legacy service-plan ID plus API key via a Bearer header, while WhatsApp, Voice, and Verification use OAuth client-credentials generated from key ID and key secret. Centralizing those paths in one module is what lets the agent call any channel without knowing the plumbing.

Quick Start: a working server in five minutes

  1. Grab credentials from the Sinch Build dashboard: PROJECT_ID, KEY_ID, and KEY_SECRET under Settings > Access Keys, and the SMS service plan ID plus API key under your SMS service. Sinch offers a free trial number, though trial numbers can only message the number you registered with until you rent a production number.
  2. npm init -y && npm i fastmcp@2 zod@4 tsx
  3. Save the server below as src/index.ts, export the environment variables, and run npx tsx src/index.ts.
  4. Add the Claude Desktop config, restart, and ask: "Send an SMS to +14155551234 saying 'Your order shipped' from the default sender."
  5. The official one-line alternative is npx -y @sinch/mcp, which wires the whole 15-tool surface without code. Build the custom server when you need tag filtering, your own retry policy, or stricter egress rules.

Building the server

Here is the complete src/index.ts, with Zod 4 input schemas, OAuth token caching, retry with jitter, and delivery-status awareness. There is no truncation.

import { FastMCP } from "fastmcp";
import { z } from "zod";

const SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID;
const SMS_API_KEY = process.env.SINCH_API_KEY;
const PROJECT_ID = process.env.SINCH_PROJECT_ID;
const KEY_ID = process.env.SINCH_KEY_ID;
const KEY_SECRET = process.env.SINCH_KEY_SECRET;
const DEFAULT_FROM = process.env.SINCH_DEFAULT_FROM;
const REGION = process.env.SINCH_REGION ?? "https://fra1.conversation.api.sinch.com";
const SMS_API = "https://sms.api.sinch.com/xms/v1";
const VERIFY_API = "https://verification.api.sinch.com/verification/v1";

const E164 = /^\+[1-9]\d{7,14}$/;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

let cachedToken = { value: "", expiresAt: 0 };

async function sinchOAuthToken(scope) {
  if (cachedToken.value && Date.now() < cachedToken.expiresAt) return cachedToken.value;
  const basic = Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString("base64");
  const res = await fetch("https://auth.sinch.com/oauth2/token", {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ grant_type: "client_credentials", scope }),
  });
  if (!res.ok) throw new Error(`OAuth token failed: ${res.status} ${await res.text()}`);
  const data = await res.json();
  cachedToken = { value: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 - 30_000 };
  return data.access_token;
}

async function withRetry(fn, label) {
  const max = 3;
  for (let attempt = 1; attempt <= max; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = err.status ?? 0;
      if (status === 429 || status >= 500) {
        if (attempt === max) throw err;
        await sleep(Math.pow(2, attempt) * 250 + Math.random() * 200);
      } else {
        throw err;
      }
    }
  }
  throw new Error(`Unreachable: ${label}`);
}

const server = new FastMCP({ name: "sinch-communications", version: "1.0.0" });

server.addTool({
  name: "send_sms",
  description: "Send an SMS via Sinch XMS. Returns a batch ID and per-recipient QUEUED status.",
  inputSchema: z.object({
    to: z.array(z.string().regex(E164)).min(1).max(100).describe("Recipients in E.164 format"),
    body: z.string().min(1).max(1600).describe("Message text; long messages concatenate automatically"),
    from: z.string().min(1).max(11).optional().describe("Sender ID or number; defaults to SINCH_DEFAULT_FROM"),
  }),
  async execute({ to, body, from }) {
    const batch = await withRetry(async () => {
      const res = await fetch(`${SMS_API}/${SERVICE_PLAN_ID}/batches`, {
        method: "POST",
        headers: { Authorization: `Bearer ${SMS_API_KEY}`, "Content-Type": "application/json" },
        body: JSON.stringify({ to, from: from ?? DEFAULT_FROM, body }),
      });
      if (!res.ok) {
        const e = new Error(`SMS send failed: ${res.status} ${await res.text()}`);
        e.status = res.status;
        throw e;
      }
      return await res.json();
    }, "send_sms");
    return {
      batchId: batch.id,
      recipients: (batch.to ?? []).map((number) => ({ number, status: "QUEUED" })),
    };
  },
});

server.addTool({
  name: "send_whatsapp",
  description: "Send a WhatsApp text message via the Sinch Conversation API.",
  inputSchema: z.object({
    to: z.string().regex(E164).describe("Recipient in E.164 format"),
    text: z.string().min(1).max(2000),
    appId: z.string().min(1).describe("Conversation API app ID"),
  }),
  async execute({ to, text, appId }) {
    const token = await sinchOAuthToken("conversation:message:send");
    const payload = {
      app_id: appId,
      recipient: { contact: { channel: "WHATSAPP", identity: to } },
      message: { text_message: { text } },
    };
    const res = await fetch(`${REGION}/v1/projects/${PROJECT_ID}/messages:send`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`WhatsApp send failed: ${res.status} ${await res.text()}`);
    const data = await res.json();
    return { messageId: data.message_id, state: data.message?.status ?? "QUEUED" };
  },
});

server.addTool({
  name: "check_delivery_status",
  description: "Check delivery status of an SMS batch by batch ID.",
  inputSchema: z.object({ batchId: z.string().min(1).describe("Batch ID from send_sms") }),
  async execute({ batchId }) {
    const res = await fetch(`${SMS_API}/${SERVICE_PLAN_ID}/batches/${batchId}`, {
      headers: { Authorization: `Bearer ${SMS_API_KEY}` },
    });
    if (!res.ok) throw new Error(`Status check failed: ${res.status} ${await res.text()}`);
    const data = await res.json();
    return { batchId, total: (data.to ?? []).length, recipients: data.to ?? [] };
  },
});

server.addTool({
  name: "initiate_voice_call",
  description: "Place a TTS voice callout via the Sinch Voice calling API.",
  inputSchema: z.object({
    to: z.string().regex(E164).describe("Recipient in E.164 format"),
    text: z.string().min(1).max(1000).describe("Text to read aloud"),
    from: z.string().min(1).optional().describe("CLI number; defaults to SINCH_DEFAULT_FROM"),
    locale: z.string().default("en-US"),
  }),
  async execute({ to, text, from, locale }) {
    const basic = Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString("base64");
    const payload = {
      method: "ttsCallout",
      ttsCallout: {
        cli: from ?? DEFAULT_FROM,
        destination: { type: "number", endpoint: to },
        locale,
        text,
      },
    };
    const res = await fetch("https://callingapi.sinch.com/calling/v1/callouts", {
      method: "POST",
      headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`Voice callout failed: ${res.status} ${await res.text()}`);
    const data = await res.json();
    return { callId: data.callId, status: "INITIATED" };
  },
});

server.addTool({
  name: "send_otp",
  description: "Send a one-time passcode via SMS using Sinch Verification.",
  inputSchema: z.object({
    to: z.string().regex(E164).describe("Recipient in E.164 format"),
    template: z.string().default("Your verification code is {{CODE}}."),
    expiry: z.number().int().min(60).max(3600).default(600).describe("Code lifetime in seconds"),
  }),
  async execute({ to, template, expiry }) {
    const basic = Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString("base64");
    const payload = {
      identity: { type: "number", endpoint: to },
      method: "sms",
      sms: { template },
    };
    const res = await fetch(`${VERIFY_API}/verifications`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Basic ${basic}` },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`OTP send failed: ${res.status} ${await res.text()}`);
    const data = await res.json();
    return { verificationId: data.id, method: "sms", expiresIn: expiry };
  },
});

server.addTool({
  name: "verify_otp",
  description: "Verify a one-time passcode reported by the user.",
  inputSchema: z.object({
    verificationId: z.string().min(1).describe("ID from send_otp"),
    code: z.string().regex(/^\d{4,10}$/).describe("Code entered by the user"),
  }),
  async execute({ verificationId, code }) {
    const basic = Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString("base64");
    const res = await fetch(`${VERIFY_API}/verifications/${verificationId}?method=sms`, {
      method: "PUT",
      headers: { "Content-Type": "application/json", Authorization: `Basic ${basic}` },
      body: JSON.stringify({ method: "sms", sms: { code } }),
    });
    const data = await res.json();
    if (res.status === 400 || data.status === "FAIL" || data.status === "expired") {
      return { verified: false, reason: data.reason ?? data.status };
    }
    if (!res.ok) throw new Error(`OTP verify failed: ${res.status} ${await res.text()}`);
    return { verified: true, status: data.status };
  },
});

await server.start();

Two production decisions are worth calling out. First, the OTP code never appears in tool output or logs; send_otp returns only the verification ID, and verify_otp returns a boolean plus a reason. That is a hard rule for any authentication-adjacent surface. Second, delivery status is a first-class concept: send returns QUEUED, and check_delivery_status lets the agent poll to DELIVERED or FAILED, which is how you avoid the "it left the platform, therefore it arrived" trap.

Wiring Claude Desktop and Cursor

Claude Desktop, in claude_desktop_config.json:

{
  "mcpServers": {
    "sinch": {
      "command": "npx",
      "args": ["tsx", "/abs/path/to/sinch-mcp/src/index.ts"],
      "env": {
        "SINCH_SERVICE_PLAN_ID": "service-plan-id",
        "SINCH_API_KEY": "sms-api-key",
        "SINCH_PROJECT_ID": "project-id",
        "SINCH_KEY_ID": "key-id",
        "SINCH_KEY_SECRET": "key-secret",
        "SINCH_DEFAULT_FROM": "+14155551234"
      }
    }
  }
}

Cursor, in .cursor/mcp.json, with the identical block. For teams that prefer the vendor-maintained server, @sinch/mcp uses the same PROJECT_ID, KEY_ID, KEY_SECRET variables, so you can migrate between the official server and this one without touching credentials.

Security: no keys in prompts, egress allowlist

The security section is not an afterthought for a communications server; it is the whole point. A model that can send SMS and place calls has real-world blast radius.

  • Never put keys in prompts. Credentials live only in the env block of the mcpServers config. Do not pass SINCH_KEY_SECRET as a tool argument, and never let a tool echo it back. Zod rejects unexpected fields, so the agent literally cannot smuggle a secret through an input schema it does not define.
  • Egress allowlist. On the host that runs the server, restrict outbound traffic to the exact Sinch domains the tools need: auth.sinch.com, sms.api.sinch.com, callingapi.sinch.com, verification.api.sinch.com, and your Conversation region (for example fra1.conversation.api.sinch.com). If the server cannot reach anything else, a compromised agent cannot exfiltrate to an attacker's domain. This is the same principle behind GitHub's enterprise MCP allowlist applied at the network layer.
  • Log hygiene. Never log full message bodies, phone numbers, or OTP codes; log batch IDs and delivery statuses only. Apply the same hashing discipline we used for privacy in zero-trust multi-agent deployments.
  • Least-privilege credentials. Create a Sinch API key scoped to exactly the channels the server uses. The verification key does not need email permissions, and the SMS key does not need number-rental rights.
  • Verify package provenance. Install @sinch/mcp or @sinch/sdk-core from the official registry and pin versions. Malicious lookalike MCP packages are a live supply-chain threat in 2026; verify the publisher before adding any server to your config.

Error handling and edge cases

  • 429 rate limits: honor Retry-After when present and otherwise back off exponentially with jitter. Sinch throttles burst sends; the withRetry wrapper in the server does exactly this.
  • 401 expired token: the OAuth cache refreshes tokens automatically before expiry (30-second safety margin), but if a 401 still appears, clear the cache and re-authenticate once before surfacing the error.
  • Message status: a QUEUED send is not a delivered message. Check delivery status before an agent tells a user anything. Map FAILED results back to the batch so retries do not double-send.
  • Duplicate sends on retry: when a timeout leaves you unsure whether a batch was accepted, poll check_delivery_status for the batch ID instead of re-issuing send_sms. That is the idempotency fix we learned the hard way at SaaSNext.
  • OTP abuse: enforce single active code per number, short expiry (300 to 600 seconds), and a rate limit on verify attempts to stop brute force. Never return the code; the user must enter it.
  • Trial restrictions: a free Sinch number can only message your registered number. Surface that constraint in tool descriptions so the agent does not report a false failure in production expectations.

Conclusion

Sinch Agent Tools made communications an agent-native surface in August 2026, and the MCP server is the fastest on-ramp: send an SMS, open a WhatsApp conversation, place a TTS callout, or run an OTP verification from any MCP-compatible client. The server in this guide keeps that power safe with OAuth and API-key credential separation, Zod-validated inputs, retry and delivery-status handling, and a strict egress allowlist. Start with npx -y @sinch/mcp to validate the idea, then adopt this custom build when you need per-channel retry policy and network-level control. For the surrounding gateway patterns, see how Kong translates REST APIs into MCP tools and how Composio centralizes OAuth for 500+ SaaS integrations.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

References: Sinch Agent Tools, Sinch MCP server on GitHub, Sinch developer docs, @sinch/mcp on npm, Sinch SDKs, Model Context Protocol introduction.

Tested with MCP SDK 2026-07-28 on August 2026.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
Sinch launched Agent Tools in August 2026 as a developer toolkit with an MCP server, Skills files, CLI, SDKs, and serverless Functions that expose Sinch's SMS, WhatsApp, voice, email, and verification APIs to AI coding agents and MCP-compatible clients.
SMS uses a service-plan ID plus API key from the Sinch Build dashboard, while WhatsApp, Voice, and Verification use the project ID plus key ID and key secret to mint OAuth client-credentials tokens.
Treat retries as an idempotency problem: when a timeout leaves you unsure whether a batch was accepted, poll check_delivery_status for the batch ID instead of re-issuing send_sms.
Keep credentials in the mcpServers env block and never in prompts, egress-allowlist only the Sinch domains the tools need, avoid logging OTP codes or message bodies, and verify npm package provenance before installing.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc