mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Add MCP server for the public API
Introduce mcp-server/ — a stdio Model Context Protocol server that wraps the /api/v1 public API so MCP clients (Claude, Cursor, etc.) can drive a self-hosted wacrm instance in natural language. - Thin wrapper over the existing REST API; all auth, scoping, and rate limiting stay server-side. No new backend surface. - Read-only by default: write and broadcast tools are only registered when WACRM_ENABLE_WRITES / WACRM_ENABLE_BROADCASTS are set, layered on top of the API key's own scopes. send_broadcast also requires an explicit confirm and is marked destructive. - Registry-ready: server.json manifest, package.json mcpName linked to it (io.github.arnasdon/wacrm-mcp), and glama.json for discovery. - Docs: docs/mcp.md + README bullet + mcp-server/README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5dbc786fdc
commit
78f2f79d3b
@ -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
58
docs/mcp.md
Normal 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
19
mcp-server/.env.example
Normal 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
4
mcp-server/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
114
mcp-server/README.md
Normal file
114
mcp-server/README.md
Normal 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
6
mcp-server/glama.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
||||
"maintainers": [
|
||||
"ArnasDon"
|
||||
]
|
||||
}
|
||||
1219
mcp-server/package-lock.json
generated
Normal file
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
49
mcp-server/package.json
Normal 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
51
mcp-server/server.json
Normal 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
179
mcp-server/src/client.ts
Normal 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
64
mcp-server/src/config.ts
Normal 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
45
mcp-server/src/index.ts
Normal 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);
|
||||
});
|
||||
63
mcp-server/src/tools/broadcast.ts
Normal file
63
mcp-server/src/tools/broadcast.ts
Normal 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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
29
mcp-server/src/tools/index.ts
Normal file
29
mcp-server/src/tools/index.ts
Normal 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;
|
||||
}
|
||||
127
mcp-server/src/tools/read.ts
Normal file
127
mcp-server/src/tools/read.ts
Normal 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))),
|
||||
);
|
||||
}
|
||||
45
mcp-server/src/tools/shared.ts
Normal file
45
mcp-server/src/tools/shared.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
95
mcp-server/src/tools/write.ts
Normal file
95
mcp-server/src/tools/write.ts
Normal 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
19
mcp-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user