mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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 —
|
- **Public REST API** (`/api/v1`) with scoped, revocable API keys —
|
||||||
build your own automations on top of your CRM. See
|
build your own automations on top of your CRM. See
|
||||||
[docs/public-api.md](./docs/public-api.md).
|
[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?
|
## 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