Merge pull request #355 from ArnasDon/feature/mcp-server

Add MCP server for the public API
This commit is contained in:
Arnas Donauskas 2026-07-08 17:34:03 +03:00 committed by GitHub
commit 9441533458
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2190 additions and 0 deletions

View File

@ -50,6 +50,10 @@ clone or fork it to run your own CRM.
- **Public REST API** (`/api/v1`) with scoped, revocable API keys —
build your own automations on top of your CRM. See
[docs/public-api.md](./docs/public-api.md).
- **MCP server** — drive your CRM from Claude, Cursor, and other AI
assistants over the [Model Context Protocol](https://modelcontextprotocol.io).
Read-only by default, opt-in writes. See [docs/mcp.md](./docs/mcp.md)
(server in [`mcp-server/`](./mcp-server)).
## Why fork this?

58
docs/mcp.md Normal file
View File

@ -0,0 +1,58 @@
# MCP server
wacrm ships a [Model Context Protocol](https://modelcontextprotocol.io)
server so you can drive your CRM from AI assistants — Claude Desktop,
Claude Code, Cursor, and any other MCP client — in natural language:
> "How many conversations are still open today?"
> "Show the last five messages with +1 415 555 0123."
> "Send the `order_update` template to that contact."
It lives in [`mcp-server/`](../mcp-server) and is published to npm as
[`wacrm-mcp`](https://www.npmjs.com/package/wacrm-mcp). Under the hood
it's a thin wrapper over the [public API](./public-api.md), so every
request is authenticated and scoped by your instance exactly like any
other API call.
## Quick start
1. Create an API key in the dashboard: **Settings → API keys**. Grant
only the scopes your assistant needs (a read-only assistant only
needs the `*:read` scopes).
2. Add the server to your MCP client config:
```jsonc
{
"mcpServers": {
"wacrm": {
"command": "npx",
"args": ["-y", "wacrm-mcp"],
"env": {
"WACRM_BASE_URL": "https://crm.example.com",
"WACRM_API_KEY": "wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
}
```
That's **read-only** — the safe default. To let the assistant change
data or send messages, add `"WACRM_ENABLE_WRITES": "true"` (and
`"WACRM_ENABLE_BROADCASTS": "true"` for mass sends) to `env`.
## What it exposes
- **Reads (always on):** `whoami`, contacts (list/get), conversations
(list/get), messages (list), broadcast status.
- **Writes (opt-in):** send a message, create/update a contact.
- **Broadcasts (opt-in):** launch a template broadcast — requires an
explicit `confirm` and is marked destructive.
## Safety
Because sending WhatsApp messages is a real side effect, the server is
**read-only until you opt in**, layered on top of the API key's own
scopes. Give an assistant a read-only key and read-only config and it
physically cannot send anything. See the
[server README](../mcp-server/README.md) for the full tool list and
safety model.

19
mcp-server/.env.example Normal file
View File

@ -0,0 +1,19 @@
# The base URL of your wacrm instance (no trailing slash).
WACRM_BASE_URL=https://your-crm.example.com
# A wacrm API key. Create one in the dashboard:
# Settings → API keys → New API key.
# The key's scopes decide what the server can do — grant the minimum.
WACRM_API_KEY=wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# --- Write guards (opt-in) ------------------------------------------
# By default the server is READ-ONLY: it never exposes tools that
# change data or send messages, no matter what the API key allows.
# Turn these on deliberately.
# Expose contact create/update and single-message sending.
# WACRM_ENABLE_WRITES=true
# Expose the broadcast tool (mass sends — highest risk). Requires
# WACRM_ENABLE_WRITES as well.
# WACRM_ENABLE_BROADCASTS=true

4
mcp-server/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.log
.env

114
mcp-server/README.md Normal file
View File

@ -0,0 +1,114 @@
# wacrm MCP server
A [Model Context Protocol](https://modelcontextprotocol.io) server for
**[wacrm](https://github.com/ArnasDon/wacrm)** — the self-hostable
WhatsApp CRM. It lets MCP clients (Claude Desktop, Claude Code, Cursor,
and others) drive your CRM in natural language:
> "How many conversations are still open?"
> "Find the contact for +1 415 555 0123 and show the last few messages."
> "Draft and send an order-update template to Jane."
It's a thin wrapper over wacrm's public [`/api/v1`](../docs/public-api.md)
REST API. All auth, scoping, and rate limiting are enforced by your
wacrm instance — this server just exposes the API as MCP tools.
## Prerequisites
1. A running wacrm instance (your own self-hosted deploy).
2. An API key: in the dashboard go to **Settings → API keys → New API
key** and grant only the scopes you need. The key is shown once.
## Install & configure
The server reads two required environment variables and two optional
write guards:
| Variable | Required | Purpose |
| ------------------------- | -------- | -------------------------------------------------------------- |
| `WACRM_BASE_URL` | yes | Your instance URL, e.g. `https://crm.example.com` |
| `WACRM_API_KEY` | yes | An API key from the dashboard |
| `WACRM_ENABLE_WRITES` | no | `true` to expose contact writes + message sending |
| `WACRM_ENABLE_BROADCASTS` | no | `true` to expose mass broadcasts (needs `WACRM_ENABLE_WRITES`) |
### Claude Desktop / Claude Code / Cursor
Add to your MCP client config (e.g. `claude_desktop_config.json`, or
`.mcp.json` for Claude Code):
```jsonc
{
"mcpServers": {
"wacrm": {
"command": "npx",
"args": ["-y", "wacrm-mcp"],
"env": {
"WACRM_BASE_URL": "https://crm.example.com",
"WACRM_API_KEY": "wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
}
```
That configuration is **read-only** — the safe default. To let the
assistant change data or send messages, add the write guards:
```jsonc
"env": {
"WACRM_BASE_URL": "https://crm.example.com",
"WACRM_API_KEY": "wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxx",
"WACRM_ENABLE_WRITES": "true",
"WACRM_ENABLE_BROADCASTS": "true"
}
```
## Tools
Read tools are always available. Write and broadcast tools appear only
when their guard is set.
| Tool | Group | Scope needed | What it does |
| -------------------- | --------- | -------------------- | ----------------------------------------------- |
| `whoami` | read | _(any valid key)_ | Show the account + scopes the key carries |
| `list_contacts` | read | `contacts:read` | List/search contacts (paginated) |
| `get_contact` | read | `contacts:read` | Read one contact |
| `list_conversations` | read | `conversations:read` | List conversations, filter by status/contact |
| `get_conversation` | read | `conversations:read` | Read one conversation |
| `list_messages` | read | `messages:read` | List a conversation's messages |
| `get_broadcast` | read | `broadcasts:send` | Poll a broadcast's delivery status |
| `send_message` | write | `messages:send` | Send a WhatsApp message (text/template/media) |
| `create_contact` | write | `contacts:write` | Create (find-or-create) a contact |
| `update_contact` | write | `contacts:write` | Update a contact / replace its tags |
| `send_broadcast` | broadcast | `broadcasts:send` | Launch a template broadcast (requires `confirm`)|
## Safety model
Sending WhatsApp messages through an LLM is a real-world side effect, so
the server layers three guards:
1. **Read-only by default.** Write and broadcast tools are not even
registered — the model can't see them — unless you opt in via
`WACRM_ENABLE_WRITES` / `WACRM_ENABLE_BROADCASTS`.
2. **API-key scopes.** Whatever the guards allow, your wacrm instance
still enforces the key's scopes. A call without the right scope
returns a clean `forbidden` error. Issue a read-only key for a
read-only assistant.
3. **Explicit broadcast confirmation.** `send_broadcast` refuses to run
unless called with `confirm: true`, and is marked `destructive` so
compliant clients prompt the user first.
## Development
```bash
npm install
npm run build # compile to dist/
npm run typecheck
npm start # run the compiled server (needs the env vars)
```
Logs go to **stderr** — stdout is reserved for the MCP protocol.
## License
MIT — same as wacrm.

6
mcp-server/glama.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": [
"ArnasDon"
]
}

1219
mcp-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
mcp-server/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "wacrm-mcp",
"version": "0.1.0",
"mcpName": "io.github.arnasdon/wacrm-mcp",
"description": "Model Context Protocol (MCP) server for wacrm — drive your self-hosted WhatsApp CRM from Claude, Cursor, and other MCP clients.",
"license": "MIT",
"author": "Arnas Donauskas",
"homepage": "https://github.com/ArnasDon/wacrm",
"repository": {
"type": "git",
"url": "git+https://github.com/ArnasDon/wacrm.git",
"directory": "mcp-server"
},
"keywords": [
"mcp",
"model-context-protocol",
"wacrm",
"whatsapp",
"crm",
"claude",
"ai"
],
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"bin": {
"wacrm-mcp": "./dist/index.js"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.18.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^20.19.0",
"typescript": "^5.7.0"
}
}

51
mcp-server/server.json Normal file
View File

@ -0,0 +1,51 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
"name": "io.github.arnasdon/wacrm-mcp",
"description": "Drive a self-hosted wacrm WhatsApp CRM (contacts, conversations, messages, broadcasts) from MCP clients.",
"repository": {
"url": "https://github.com/ArnasDon/wacrm",
"source": "github",
"subfolder": "mcp-server"
},
"version": "0.1.0",
"packages": [
{
"registryType": "npm",
"identifier": "wacrm-mcp",
"version": "0.1.0",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"name": "WACRM_BASE_URL",
"description": "Base URL of your wacrm instance, e.g. https://crm.example.com",
"isRequired": true,
"isSecret": false,
"format": "string"
},
{
"name": "WACRM_API_KEY",
"description": "wacrm API key (Settings → API keys). Its scopes decide what is allowed.",
"isRequired": true,
"isSecret": true,
"format": "string"
},
{
"name": "WACRM_ENABLE_WRITES",
"description": "Set to true to expose contact create/update and message sending. Read-only when unset.",
"isRequired": false,
"isSecret": false,
"format": "string"
},
{
"name": "WACRM_ENABLE_BROADCASTS",
"description": "Set to true to expose the mass-broadcast tool. Requires WACRM_ENABLE_WRITES.",
"isRequired": false,
"isSecret": false,
"format": "string"
}
]
}
]
}

179
mcp-server/src/client.ts Normal file
View File

@ -0,0 +1,179 @@
// ============================================================
// wacrm public API client.
//
// A thin wrapper over the `/api/v1` REST surface. It attaches the
// bearer key, unwraps the `{ data }` / `{ error }` envelope, and
// turns API failures into a typed WacrmApiError the tools can render
// cleanly. Nothing here knows about MCP — it's just the CRM API.
// ============================================================
import type { Config } from './config.js';
/** A structured error from the wacrm API envelope (`{ error: { code, message } }`). */
export class WacrmApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = 'WacrmApiError';
this.status = status;
this.code = code;
}
}
export interface Paginated<T> {
data: T[];
next_cursor: string | null;
}
export class WacrmClient {
private readonly baseUrl: string;
private readonly apiKey: string;
constructor(config: Pick<Config, 'baseUrl' | 'apiKey'>) {
this.baseUrl = config.baseUrl;
this.apiKey = config.apiKey;
}
private async request<T>(
method: string,
path: string,
options: { query?: Record<string, string | number | undefined>; body?: unknown } = {},
): Promise<{ data: T; meta?: { next_cursor: string | null } }> {
const url = new URL(`${this.baseUrl}/api/v1${path}`);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
}
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
Accept: 'application/json',
};
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
}
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
} catch (err) {
throw new WacrmApiError(
0,
'network_error',
`Could not reach wacrm at ${this.baseUrl}: ${(err as Error).message}`,
);
}
// 429s carry a Retry-After we surface to the model.
let payload: unknown = undefined;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
// Non-JSON body (e.g. an upstream proxy error page).
if (!res.ok) {
throw new WacrmApiError(res.status, 'internal', text.slice(0, 500));
}
}
}
if (!res.ok) {
const envelope = payload as { error?: { code?: string; message?: string } } | undefined;
const code = envelope?.error?.code ?? 'internal';
let message = envelope?.error?.message ?? `Request failed with status ${res.status}`;
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After');
if (retryAfter) message += ` (retry after ${retryAfter}s)`;
}
throw new WacrmApiError(res.status, code, message);
}
const envelope = payload as { data: T; meta?: { next_cursor: string | null } };
return { data: envelope.data, meta: envelope.meta };
}
private async list<T>(
path: string,
query: Record<string, string | number | undefined>,
): Promise<Paginated<T>> {
const res = await this.request<T[]>('GET', path, { query });
return { data: res.data, next_cursor: res.meta?.next_cursor ?? null };
}
// --- Identity -----------------------------------------------------
me(): Promise<{ data: unknown }> {
return this.request('GET', '/me');
}
// --- Messages -----------------------------------------------------
sendMessage(body: unknown): Promise<{ data: unknown }> {
return this.request('POST', '/messages', { body });
}
// --- Contacts -----------------------------------------------------
listContacts(query: {
limit?: number;
cursor?: string;
search?: string;
tag?: string;
}): Promise<Paginated<unknown>> {
return this.list('/contacts', query);
}
getContact(id: string): Promise<{ data: unknown }> {
return this.request('GET', `/contacts/${encodeURIComponent(id)}`);
}
createContact(body: unknown): Promise<{ data: unknown }> {
return this.request('POST', '/contacts', { body });
}
updateContact(id: string, body: unknown): Promise<{ data: unknown }> {
return this.request('PATCH', `/contacts/${encodeURIComponent(id)}`, { body });
}
// --- Conversations ------------------------------------------------
listConversations(query: {
limit?: number;
cursor?: string;
status?: string;
contact_id?: string;
}): Promise<Paginated<unknown>> {
return this.list('/conversations', query);
}
getConversation(id: string): Promise<{ data: unknown }> {
return this.request('GET', `/conversations/${encodeURIComponent(id)}`);
}
listConversationMessages(
id: string,
query: { limit?: number; cursor?: string },
): Promise<Paginated<unknown>> {
return this.list(`/conversations/${encodeURIComponent(id)}/messages`, query);
}
// --- Broadcasts ---------------------------------------------------
sendBroadcast(body: unknown): Promise<{ data: unknown }> {
return this.request('POST', '/broadcasts', { body });
}
getBroadcast(id: string): Promise<{ data: unknown }> {
return this.request('GET', `/broadcasts/${encodeURIComponent(id)}`);
}
}

64
mcp-server/src/config.ts Normal file
View File

@ -0,0 +1,64 @@
// ============================================================
// Configuration — read once at startup from the environment.
//
// The server needs the URL of a wacrm instance and an API key.
// Two opt-in flags decide whether write / broadcast tools are
// registered at all: by default the server is READ-ONLY, so an
// MCP client can never see a tool that mutates data or sends a
// message unless the operator turns it on deliberately. The API
// key's own scopes are still enforced server-side on top of this —
// this is a second, client-side guard, not a replacement.
// ============================================================
export interface Config {
baseUrl: string;
apiKey: string;
enableWrites: boolean;
enableBroadcasts: boolean;
}
function truthy(value: string | undefined): boolean {
if (!value) return false;
const v = value.trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}
export function loadConfig(): Config {
const baseUrlRaw = process.env.WACRM_BASE_URL?.trim();
const apiKey = process.env.WACRM_API_KEY?.trim();
const missing: string[] = [];
if (!baseUrlRaw) missing.push('WACRM_BASE_URL');
if (!apiKey) missing.push('WACRM_API_KEY');
if (missing.length > 0) {
throw new Error(
`Missing required environment variable(s): ${missing.join(', ')}. ` +
`Set WACRM_BASE_URL to your instance URL (e.g. https://crm.example.com) ` +
`and WACRM_API_KEY to a key from Settings → API keys.`,
);
}
// Normalise: strip a trailing slash so path joins are predictable.
const baseUrl = baseUrlRaw!.replace(/\/+$/, '');
if (!/^https?:\/\//.test(baseUrl)) {
throw new Error(
`WACRM_BASE_URL must start with http:// or https:// (got "${baseUrl}").`,
);
}
const enableWrites = truthy(process.env.WACRM_ENABLE_WRITES);
const enableBroadcasts = truthy(process.env.WACRM_ENABLE_BROADCASTS);
if (enableBroadcasts && !enableWrites) {
throw new Error(
'WACRM_ENABLE_BROADCASTS requires WACRM_ENABLE_WRITES to also be set.',
);
}
return {
baseUrl,
apiKey: apiKey!,
enableWrites,
enableBroadcasts,
};
}

45
mcp-server/src/index.ts Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env node
// ============================================================
// wacrm MCP server — entry point.
//
// A stdio Model Context Protocol server that exposes the wacrm
// public API (`/api/v1`) as MCP tools, so an MCP client (Claude
// Desktop, Cursor, etc.) can drive a self-hosted WhatsApp CRM in
// natural language.
//
// Transport is stdio: logs MUST go to stderr, never stdout (stdout
// is the protocol channel). Configuration comes from the environment
// — see .env.example / README.md.
// ============================================================
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { loadConfig } from './config.js';
import { WacrmClient } from './client.js';
import { registerTools } from './tools/index.js';
// package.json version, kept in sync manually with the manifest.
const VERSION = '0.1.0';
async function main(): Promise<void> {
const config = loadConfig();
const client = new WacrmClient(config);
const server = new McpServer({ name: 'wacrm-mcp', version: VERSION });
const groups = registerTools(server, client, config);
const transport = new StdioServerTransport();
await server.connect(transport);
// Stderr only — stdout is reserved for the MCP protocol.
console.error(
`wacrm MCP server v${VERSION} ready — instance ${config.baseUrl}, ` +
`tool groups: ${groups.join(', ')}` +
(config.enableWrites ? '' : ' (read-only; set WACRM_ENABLE_WRITES to allow changes)'),
);
}
main().catch((err) => {
console.error(`Failed to start wacrm MCP server: ${(err as Error).message}`);
process.exit(1);
});

View File

@ -0,0 +1,63 @@
// ============================================================
// Broadcast tool — the highest-risk action.
//
// Registered only when BOTH WACRM_ENABLE_WRITES and
// WACRM_ENABLE_BROADCASTS are set. A single call can message up to
// 1000 people, so on top of the env gate the tool requires an
// explicit `confirm: true` argument — the model must consciously opt
// in, and a client that echoes tool args gives the user a last look.
// Marked destructive so hosts prompt before running it.
// ============================================================
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { WacrmClient } from '../client.js';
import { errorResult, handle, jsonResult } from './shared.js';
export function registerBroadcastTools(server: McpServer, client: WacrmClient): void {
server.registerTool(
'send_broadcast',
{
title: 'Send broadcast',
description:
'Launch a template broadcast to a list of recipients (up to 1000). This sends a real WhatsApp template message to every recipient — a mass, irreversible action. You MUST set confirm=true, and you should show the full recipient list and template to the user for approval before calling. The call returns fast; poll get_broadcast for delivery progress.',
inputSchema: {
name: z.string().describe('A name for this broadcast campaign (for your own reference).'),
template_name: z.string().describe('Meta-approved template name.'),
template_language: z.string().describe('Template language code, e.g. "en_US".'),
recipients: z
.array(
z.object({
to: z.string().describe('Recipient phone number in E.164 format.'),
params: z
.array(z.string())
.optional()
.describe('Positional template body variables for this recipient.'),
}),
)
.min(1)
.max(1000)
.describe('Recipients (1–1000). Invalid numbers are dropped and counted as rejected.'),
confirm: z
.boolean()
.describe('Must be true to actually send. A safety gate against accidental mass sends.'),
},
annotations: {
title: 'Send broadcast',
readOnlyHint: false,
destructiveHint: true,
openWorldHint: true,
},
},
handle(async ({ confirm, ...body }) => {
if (confirm !== true) {
return errorResult(
'Refusing to send: confirm must be true. This launches a mass broadcast to ' +
`${body.recipients.length} recipient(s). Confirm the recipient list and template ` +
'with the user, then call again with confirm=true.',
);
}
return jsonResult(await client.sendBroadcast(body));
}),
);
}

View File

@ -0,0 +1,29 @@
// ============================================================
// Tool registration — decides which tools exist for this process
// based on the write guards. Reads are always on; writes and
// broadcasts are opt-in (see config.ts).
// ============================================================
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { WacrmClient } from '../client.js';
import type { Config } from '../config.js';
import { registerReadTools } from './read.js';
import { registerWriteTools } from './write.js';
import { registerBroadcastTools } from './broadcast.js';
export function registerTools(server: McpServer, client: WacrmClient, config: Config): string[] {
const enabled: string[] = ['read'];
registerReadTools(server, client);
if (config.enableWrites) {
registerWriteTools(server, client);
enabled.push('write');
}
if (config.enableBroadcasts) {
registerBroadcastTools(server, client);
enabled.push('broadcast');
}
return enabled;
}

View File

@ -0,0 +1,127 @@
// ============================================================
// Read-only tools — always registered.
//
// whoami + list/read of contacts, conversations, messages, and
// broadcast status. None of these change state, so they're safe to
// expose unconditionally. Each carries readOnlyHint so clients can
// surface them without a confirmation prompt.
// ============================================================
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { WacrmClient } from '../client.js';
import { handle, jsonResult } from './shared.js';
const READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const;
export function registerReadTools(server: McpServer, client: WacrmClient): void {
server.registerTool(
'whoami',
{
title: 'Who am I',
description:
'Verify the API key and show which wacrm account it is bound to and what scopes it carries. Call this first to discover what actions are possible.',
inputSchema: {},
annotations: { ...READ_ONLY, title: 'Who am I' },
},
handle(async () => jsonResult(await client.me())),
);
server.registerTool(
'list_contacts',
{
title: 'List contacts',
description:
'List contacts in the CRM, newest first. Optionally filter by a free-text search (matches name or phone) or by a tag id. Results are paginated: pass the returned next_cursor to fetch the next page.',
inputSchema: {
search: z.string().optional().describe('Free-text search over name or phone number.'),
tag: z.string().optional().describe('Tag id to filter by.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Page size, 1–100 (default 50).'),
cursor: z.string().optional().describe('Opaque pagination cursor from a previous response.'),
},
annotations: { ...READ_ONLY, title: 'List contacts' },
},
handle(async (args) => jsonResult(await client.listContacts(args))),
);
server.registerTool(
'get_contact',
{
title: 'Get contact',
description: 'Read a single contact by its id.',
inputSchema: {
id: z.string().describe('Contact id.'),
},
annotations: { ...READ_ONLY, title: 'Get contact' },
},
handle(async ({ id }) => jsonResult(await client.getContact(id))),
);
server.registerTool(
'list_conversations',
{
title: 'List conversations',
description:
'List conversations, newest first. Optionally filter by status (open / pending / closed) or by contact id. Paginated.',
inputSchema: {
status: z.enum(['open', 'pending', 'closed']).optional().describe('Conversation status filter.'),
contact_id: z.string().optional().describe('Only conversations for this contact.'),
limit: z.number().int().min(1).max(100).optional().describe('Page size, 1–100 (default 50).'),
cursor: z.string().optional().describe('Opaque pagination cursor.'),
},
annotations: { ...READ_ONLY, title: 'List conversations' },
},
handle(async (args) => jsonResult(await client.listConversations(args))),
);
server.registerTool(
'get_conversation',
{
title: 'Get conversation',
description: 'Read a single conversation by id, including its contact and tags.',
inputSchema: {
id: z.string().describe('Conversation id.'),
},
annotations: { ...READ_ONLY, title: 'Get conversation' },
},
handle(async ({ id }) => jsonResult(await client.getConversation(id))),
);
server.registerTool(
'list_messages',
{
title: 'List messages',
description:
'List the messages in a conversation, newest first. Each message includes its direction (inbound/outbound), delivery status, and content. Paginated.',
inputSchema: {
conversation_id: z.string().describe('The conversation to read messages from.'),
limit: z.number().int().min(1).max(100).optional().describe('Page size, 1–100 (default 50).'),
cursor: z.string().optional().describe('Opaque pagination cursor.'),
},
annotations: { ...READ_ONLY, title: 'List messages' },
},
handle(async ({ conversation_id, limit, cursor }) =>
jsonResult(await client.listConversationMessages(conversation_id, { limit, cursor })),
),
);
server.registerTool(
'get_broadcast',
{
title: 'Get broadcast status',
description:
'Read a broadcast campaign by id — its status and delivered / read / rejected counts. Use this to poll progress after launching one.',
inputSchema: {
id: z.string().describe('Broadcast id.'),
},
annotations: { ...READ_ONLY, title: 'Get broadcast status' },
},
handle(async ({ id }) => jsonResult(await client.getBroadcast(id))),
);
}

View File

@ -0,0 +1,45 @@
// ============================================================
// Shared helpers for tool handlers.
//
// Every tool returns MCP `content`. On success we hand back the JSON
// payload as pretty text (models read it fine and it keeps the tool
// layer dumb). On a WacrmApiError we return an `isError` result with
// the stable error code, so the model can reason about *why* it
// failed (missing scope, rate limit, not found) instead of seeing a
// stack trace.
// ============================================================
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { WacrmApiError } from '../client.js';
export function jsonResult(payload: unknown): CallToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
};
}
export function errorResult(message: string): CallToolResult {
return {
content: [{ type: 'text', text: message }],
isError: true,
};
}
/**
* Wrap a tool handler so any WacrmApiError becomes a clean, model-
* readable error result and unexpected throws don't crash the server.
*/
export function handle<A>(
fn: (args: A) => Promise<CallToolResult>,
): (args: A) => Promise<CallToolResult> {
return async (args: A) => {
try {
return await fn(args);
} catch (err) {
if (err instanceof WacrmApiError) {
return errorResult(`wacrm API error [${err.code}]: ${err.message}`);
}
return errorResult(`Unexpected error: ${(err as Error).message}`);
}
};
}

View File

@ -0,0 +1,95 @@
// ============================================================
// Write tools — registered only when WACRM_ENABLE_WRITES is set.
//
// These change data or send a WhatsApp message. They are gated so a
// read-only deployment never exposes them to the model at all. (The
// API key's scopes are still enforced server-side; a call without the
// right scope returns a clean `forbidden` error.)
// ============================================================
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { WacrmClient } from '../client.js';
import { handle, jsonResult } from './shared.js';
const templateSchema = z
.object({
name: z.string().describe('Meta-approved template name.'),
language: z.string().describe('Template language code, e.g. "en_US".'),
params: z
.array(z.string())
.optional()
.describe('Positional body variables, in order.'),
})
.describe('Template payload — required when type is "template".');
export function registerWriteTools(server: McpServer, client: WacrmClient): void {
server.registerTool(
'send_message',
{
title: 'Send WhatsApp message',
description:
'Send a WhatsApp message to a phone number (E.164, e.g. +14155550123). The contact and conversation are found-or-created automatically. Use type "text" for a free-form message (only valid inside the 24-hour customer-service window), or "template" to send an approved template (required to open a new conversation). Media types (image/video/document/audio) require a media_url. This sends a real message to a real person — confirm the recipient and content with the user before calling.',
inputSchema: {
to: z.string().describe('Recipient phone number in E.164 format, e.g. +14155550123.'),
type: z
.enum(['text', 'template', 'image', 'video', 'document', 'audio'])
.default('text')
.describe('Message type. Defaults to "text".'),
text: z
.string()
.optional()
.describe('Message body for "text", or the caption for a media type.'),
media_url: z
.string()
.url()
.optional()
.describe('Publicly reachable URL of the media file (required for media types).'),
filename: z.string().optional().describe('File name for a "document" send.'),
template: templateSchema.optional(),
reply_to_message_id: z
.string()
.optional()
.describe('Optional id of a message in the same conversation to reply to.'),
},
annotations: { title: 'Send WhatsApp message', readOnlyHint: false, openWorldHint: true },
},
handle(async (args) => jsonResult(await client.sendMessage(args))),
);
server.registerTool(
'create_contact',
{
title: 'Create contact',
description:
'Create a contact by phone number (E.164, required). Find-or-create: if a contact with that phone already exists it is returned unchanged. Optional: name, email, company, and tags (tag names, created if missing).',
inputSchema: {
phone: z.string().describe('Phone number in E.164 format, e.g. +14155550123.'),
name: z.string().optional(),
email: z.string().email().optional(),
company: z.string().optional(),
tags: z.array(z.string()).optional().describe('Tag names; created if they do not exist.'),
},
annotations: { title: 'Create contact', readOnlyHint: false, openWorldHint: true },
},
handle(async (args) => jsonResult(await client.createContact(args))),
);
server.registerTool(
'update_contact',
{
title: 'Update contact',
description:
'Update an existing contact. Only the fields you pass are changed. Pass tags (an array of tag names) to replace the contact’s tags entirely.',
inputSchema: {
id: z.string().describe('Contact id.'),
name: z.string().optional(),
email: z.string().email().optional(),
company: z.string().optional(),
tags: z.array(z.string()).optional().describe('Replaces the contact’s tags.'),
},
annotations: { title: 'Update contact', readOnlyHint: false, openWorldHint: true },
},
handle(async ({ id, ...body }) => jsonResult(await client.updateContact(id, body))),
);
}

19
mcp-server/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}